Scientyfic World

React Query Params: Read & Set URL Search Params (React Router, Next.js)

To read query params in React with React Router, call useSearchParams(): it returns a URLSearchParams object, so searchParams.get(‘page’) reads ?page=2, and setSearchParams() updates the query string and re-renders. In Next.js,...

Share:

Get an AI summary of this article

blog banner for Query Parameters With React Router

To read query params in React with React Router, call useSearchParams(): it returns a URLSearchParams object, so searchParams.get('page') reads ?page=2, and setSearchParams() updates the query string and re-renders. In Next.js, use useSearchParams() from next/navigation in client components or the searchParams prop in pages, and in plain React read window.location.search with URLSearchParams.

Query parameters are the glue that connects a URL to the state of your application. I’ve been building React apps professionally for years, and every project eventually needs to filter a list, paginate results or preserve a search query, and each of those features relies on query parameters. This guide covers React Router 8 (current version) and 7, Next.js 16 and plain React, with a working filter-and-pagination example. I built and clicked through every example, and the URLs and outputs shown are from those tests.

Looking for TanStack Query (formerly “React Query”)? That is a data-fetching library, not URL query parameters; see how to fetch data in React with hooks.

Query Parameters vs Route (URL) Parameters

Query parametersRoute (path) parameters
Example/products?sort=price&page=2/products/42
Defined in routes?No, any route can have themYes: path="products/:productId"
Used forOptional UI state: search, filters, sort, page, tabsIdentifying a resource
Read with (React Router)useSearchParams()useParams()
Multiple valuesRepeat the key: ?tag=desk&tag=cablesOne value per segment

Rule of thumb: if the page doesn’t make sense without the value, it belongs in the path; if it refines what the page shows, it belongs in the query string. For route parameters, see my React Router tutorial for beginners.

Setup: React Router 8

React Router 8 is installed as react-router. The old react-router-dom package was removed in v8, so import hooks from react-router and RouterProvider from react-router/dom. Everything in this guide also works in v7, where both import paths still exist.

npm install react-router
import { StrictMode } from 'react';
import { createRoot } from 'react-dom/client';
import { createBrowserRouter } from 'react-router';
import { RouterProvider } from 'react-router/dom';
import { routes } from './routes.jsx';

const router = createBrowserRouter(routes);

createRoot(document.getElementById('root')).render(
  <StrictMode>
    <RouterProvider router={router} />
  </StrictMode>,
);

useSearchParams: Get, Set and Delete Query Params

import { useSearchParams } from 'react-router';

export default function Products() {
  const [searchParams, setSearchParams] = useSearchParams();

  // Read: values are always strings (or null if missing)
  const q = searchParams.get('q') ?? '';
  const page = Number(searchParams.get('page') ?? '1');
  const tags = searchParams.getAll('tag');           // ?tag=desk&tag=cables -> ['desk', 'cables']

  // Set one key and keep the others
  const setSort = (sort) =>
    setSearchParams((prev) => {
      const next = new URLSearchParams(prev);
      next.set('sort', sort);
      next.delete('page');                            // reset pagination when the sort changes
      return next;
    });

  // Replace everything
  const clearFilters = () => setSearchParams({});

  // ...
}

Key behaviours, straight from React Router’s own documentation comments in v8:

  • setSearchParams accepts a string ("?tab=1"), an object ({ tab: '1' }, with arrays for repeated keys such as { brand: ['nike', 'reebok'] }), an array of tuples, a URLSearchParams, or a callback that receives the current params.
  • The callback form does not queue like React’s setState: two setSearchParams calls in the same tick don’t build on each other. Make all your changes in one call.
  • searchParams is a stable reference, so it is safe in useEffect dependency arrays. It is also mutable: changing it without calling setSearchParams doesn’t update the URL, so copy it with new URLSearchParams(prev) before editing.
  • Each update adds a history entry. Pass { replace: true } as the second argument for updates that shouldn’t fill the back button, such as typing in a search box.

A Complete Example: Search, Sort, Tags and Pagination

The most robust pattern is to read the query string in a route loader, which runs before the page renders, and update it from the component. Here are the route definitions (data mode), with a small timing middleware that v8 enables by default:

import { createContext, data, redirect } from 'react-router';
import { products } from './data.js';
import { parseProductQuery } from './search.js';
import Root from './Root.jsx';
import Products from './Products.jsx';
import NewProduct from './NewProduct.jsx';
import ErrorPage from './ErrorPage.jsx';

// Typed context shared between middleware and loaders
export const requestTimeContext = createContext(0);

async function timingMiddleware({ request, context }, next) {
  const start = performance.now();
  context.set(requestTimeContext, start);
  await next();                                         // run child middleware and loaders
  console.log(`${new URL(request.url).pathname} loaded in ${Math.round(performance.now() - start)} ms`);
}

async function productsLoader({ request }) {
  const url = new URL(request.url);
  const parsed = parseProductQuery(url.searchParams);
  if (!parsed.success) throw data({ message: 'Invalid search parameters' }, { status: 400 });

  const { q, sort, page, tag } = parsed.data;
  const pageSize = 2;
  const filtered = products
    .filter((p) => p.name.toLowerCase().includes(q.toLowerCase()))
    .filter((p) => tag.every((t) => p.tags.includes(t)))
    .sort((a, b) => (sort === 'price' ? a.price - b.price : a.name.localeCompare(b.name)));

  return {
    items: filtered.slice((page - 1) * pageSize, page * pageSize),
    totalPages: Math.max(1, Math.ceil(filtered.length / pageSize)),
    query: parsed.data,
  };
}

async function newProductAction({ request }) {
  const form = await request.formData();
  const name = String(form.get('name') ?? '').trim();
  if (!name) return { error: 'Name is required' };      // shown with useActionData()
  products.push({ id: products.length + 1, name, price: Number(form.get('price')) || 0, tags: [] });
  return redirect(`/products?q=${encodeURIComponent(name)}`);
}

export const routes = [
  {
    path: '/',
    element: <Root />,
    errorElement: <ErrorPage />,
    middleware: [timingMiddleware],
    children: [
      { index: true, loader: () => redirect('/products') },
      { path: 'products', element: <Products />, loader: productsLoader, errorElement: <ErrorPage /> },
      { path: 'products/new', element: <NewProduct />, action: newProductAction },
    ],
  },
];

Query strings are always strings, so validate and convert them in one place. This uses Zod 4 (I’ve covered Zod in more depth in form validation with Zod):

import { z } from 'zod';

// Query strings are always strings: coerce and validate them in one place
export const ProductQuery = z.object({
  q: z.string().trim().default(''),
  sort: z.enum(['name', 'price']).default('name'),
  page: z.coerce.number().int().min(1).default(1),
  tag: z.array(z.string()).default([]),
});

export function parseProductQuery(searchParams) {
  return ProductQuery.safeParse({
    q: searchParams.get('q') ?? undefined,
    sort: searchParams.get('sort') ?? undefined,
    page: searchParams.get('page') ?? undefined,
    tag: searchParams.getAll('tag'),          // ?tag=desk&tag=cables -> ['desk', 'cables']
  });
}

The page reads the loader’s data and writes changes back to the URL:

import { useLoaderData, useSearchParams } from 'react-router';
import { useDebouncedSearchParam } from './useDebouncedSearchParam.js';

const TAGS = ['desk', 'input', 'cables'];

export default function Products() {
  const { items, totalPages, query } = useLoaderData();
  const [searchParams, setSearchParams] = useSearchParams();
  const [search, setSearch] = useDebouncedSearchParam('q');

  // Update one key and keep the rest of the query string
  function setParam(key, value) {
    setSearchParams((prev) => {
      const next = new URLSearchParams(prev);
      next.set(key, value);
      if (key !== 'page') next.delete('page');
      return next;
    });
  }

  function toggleTag(tag) {
    setSearchParams((prev) => {
      const next = new URLSearchParams(prev);
      const tags = next.getAll('tag');
      next.delete('tag');
      for (const t of tags.includes(tag) ? tags.filter((x) => x !== tag) : [...tags, tag]) next.append('tag', t);
      next.delete('page');
      return next;
    });
  }

  return (
    <>
      <input id="search" value={search} onChange={(e) => setSearch(e.target.value)} placeholder="Search" />
      <select id="sort" value={query.sort} onChange={(e) => setParam('sort', e.target.value)}>
        <option value="name">Name</option>
        <option value="price">Price</option>
      </select>
      {TAGS.map((t) => (
        <label key={t}>
          <input type="checkbox" className="tag" value={t} checked={query.tag.includes(t)} onChange={() => toggleTag(t)} /> {t}
        </label>
      ))}
      <ul id="items">{items.map((p) => <li key={p.id}>{p.name} (${p.price})</li>)}</ul>
      <button id="prev" disabled={query.page <= 1} onClick={() => setParam('page', String(query.page - 1))}>Previous</button>
      <span id="pageinfo"> Page {query.page} of {totalPages} </span>
      <button id="next" disabled={query.page >= totalPages} onClick={() => setParam('page', String(query.page + 1))}>Next</button>
      <button id="clear" onClick={() => setSearchParams({})}>Clear filters</button>
      <p id="raw">{searchParams.toString()}</p>
    </>
  );
}

And a debounced search box, so the URL (and the loader) updates once the user pauses typing rather than on every keystroke:

import { useEffect, useRef, useState } from 'react';
import { useSearchParams } from 'react-router';

// Keep an input responsive while only writing to the URL after the user pauses typing
export function useDebouncedSearchParam(key, delay = 300) {
  const [searchParams, setSearchParams] = useSearchParams();
  const [value, setValue] = useState(searchParams.get(key) ?? '');
  const timer = useRef();

  useEffect(() => () => clearTimeout(timer.current), []);

  function update(next) {
    setValue(next);                                   // the input updates immediately
    clearTimeout(timer.current);
    timer.current = setTimeout(() => {
      setSearchParams((prev) => {
        const params = new URLSearchParams(prev);
        if (next) params.set(key, next); else params.delete(key);
        params.delete('page');                        // new search starts at page 1
        return params;
      }, { replace: true });                          // don't add a history entry per search
    }, delay);
  }

  return [value, update];
}

What happened when I clicked through it

I drove this app in headless Brave with Puppeteer. Each action produced exactly the URL you’d want:

ActionResulting URLProducts shown
Open the page/productsMechanical keyboard, Monitor arm (page 1 of 2)
Click Next/products?page=2Standing desk, USB-C hub
Sort by price/products?sort=priceUSB-C hub, Monitor arm (page reset to 1)
Tick “desk”/products?sort=price&tag=deskMonitor arm, Mechanical keyboard
Also tick “input”/products?sort=price&tag=desk&tag=inputMechanical keyboard
Clear filters/productsBack to page 1
Type “desk” in search/products?q=desk (after 300 ms)Standing desk
Open /products?page=abcunchangedError page: “400: Invalid search parameters”

Two details worth noting from the test. Typing four characters into the search box produced a single URL update and added no history entries, thanks to the debounce and replace: true. And opening /products?q=caf%C3%A9%20%26%20co put “café & co” correctly into the input: URLSearchParams decodes values for you.

Read Query Params in a Loader

In data and framework modes, loaders receive the request, so you read the query string with the standard URL API, as in productsLoader above:

export async function loader({ request }) {
  const url = new URL(request.url);
  const page = Number(url.searchParams.get('page') ?? '1');
  return fetchProducts({ page });
}

Loaders re-run whenever the URL changes, so the data always matches the query string without any useEffect. In framework mode with server rendering, the page is rendered on the server with the right data for each URL, which also helps search engines.

Search Forms Without JavaScript: Form method=”get”

A React Router <Form method="get"> serializes its fields into the query string and navigates, just like a classic HTML form, so the loader runs with the new parameters:

import { Form } from 'react-router';

<Form method="get" action="/products">
  <input name="q" placeholder="Search" />
  <button type="submit">Search</button>
</Form>
// submits to /products?q=...

Encoding: Don’t Double-Encode

URLSearchParams encodes values when writing and decodes them when reading, so don’t wrap values in encodeURIComponent() yourself. I checked what happens when you do:

const a = new URLSearchParams();
a.set('q', 'café & co');
a.toString();                                  // 'q=caf%C3%A9+%26+co'
new URLSearchParams(a.toString()).get('q');    // 'café & co'

const b = new URLSearchParams();
b.set('q', encodeURIComponent('café & co'));   // double-encoded
b.toString();                                  // 'q=caf%25C3%25A9%2520%2526%2520co'
new URLSearchParams(b.toString()).get('q');    // 'caf%C3%A9%20%26%20co'  (wrong)

Use encodeURIComponent() only when you build a URL string by hand, such as redirect(`/products?q=${encodeURIComponent(name)}`). See MDN’s URLSearchParams reference for all methods.

Type-Safe Query Params with nuqs

If you have many parameters, nuqs gives you a useState-like hook per parameter with built-in parsers. Version 2.10 ships an adapter for React Router 8 (and for v6, v7, Next.js, Remix, TanStack Router and plain React):

npm install nuqs
// main.jsx
import { NuqsAdapter } from 'nuqs/adapters/react-router/v8';

<NuqsAdapter>
  <RouterProvider router={router} />
</NuqsAdapter>
import { useQueryState, parseAsInteger, parseAsArrayOf, parseAsString } from 'nuqs';

export default function NuqsDemo() {
  const [page, setPage] = useQueryState('page', parseAsInteger.withDefault(1));
  const [tags, setTags] = useQueryState('tags', parseAsArrayOf(parseAsString).withDefault([]));

  return (
    <>
      <p id="nuqs">page={page} ({typeof page}) tags={JSON.stringify(tags)}</p>
      <button id="nuqs-next" onClick={() => setPage((p) => p + 1)}>Next page</button>
      <button id="nuqs-tag" onClick={() => setTags([...tags, 'desk'])}>Add tag</button>
    </>
  );
}

In my test, opening ?page=3 gave page=3 as a real number, “Next page” set ?page=4, adding two tags produced ?page=4&tags=desk,desk (nuqs stores arrays comma-separated by default), and an invalid ?page=oops fell back to the default of 1 instead of crashing.

Query Params in Next.js (App Router)

Next.js has its own APIs. I tested both with Next.js 16.4.3:

In a Server Component page

Pages receive a searchParams prop, which is a Promise since Next.js 15. New Next.js 16 projects also enable cacheComponents, and with it my first version failed the build with “Next.js encountered uncached or runtime data during prerendering”. Reading searchParams inside a <Suspense> boundary fixed it:

import { Suspense } from 'react';

// searchParams is a Promise; read it inside a Suspense boundary
async function Results({ searchParams }) {
  const { q = '', page = '1' } = await searchParams;
  return <p id="out">Server: q={q} page={page}</p>;
}

export default function ServerPage({ searchParams }) {
  return (
    <Suspense fallback={<p>Loading results...</p>}>
      <Results searchParams={searchParams} />
    </Suspense>
  );
}

/server?q=react&page=2 rendered “Server: q=react page=2”.

In a Client Component

Use useSearchParams() from next/navigation. It returns a read-only URLSearchParams, so you build a new string and navigate with the router. Wrap the component in <Suspense> so the rest of the page can still be prerendered:

'use client';

import { useSearchParams, useRouter, usePathname } from 'next/navigation';

export default function Filters() {
  const searchParams = useSearchParams();          // read-only URLSearchParams
  const router = useRouter();
  const pathname = usePathname();

  function setSort(sort) {
    const next = new URLSearchParams(searchParams.toString());
    next.set('sort', sort);
    router.replace(`${pathname}?${next.toString()}`);
  }

  return (
    <>
      <p id="out">Client: sort={searchParams.get('sort') ?? 'name'}</p>
      <button id="by-price" onClick={() => setSort('price')}>Sort by price</button>
    </>
  );
}
import { Suspense } from 'react';
import Filters from './Filters';

export default function ClientPage() {
  return (
    <Suspense fallback={<p>Loading filters...</p>}>
      <Filters />
    </Suspense>
  );
}

Clicking the button changed /client?q=react to /client?q=react&sort=price, keeping the existing parameter.

Query Params in Plain React (No Router)

Without a router, read window.location.search and update it with the History API. The catch is re-rendering: pushState doesn’t fire any event, so this hook dispatches its own and also listens for popstate (back and forward):

import { useSyncExternalStore } from 'react';

// Re-render when the user goes back/forward or when we push a new URL
function subscribe(callback) {
  window.addEventListener('popstate', callback);
  window.addEventListener('pushstate', callback);
  return () => {
    window.removeEventListener('popstate', callback);
    window.removeEventListener('pushstate', callback);
  };
}

export function useSearchParam(key) {
  const value = useSyncExternalStore(subscribe, () => new URLSearchParams(window.location.search).get(key));

  function setValue(next) {
    const url = new URL(window.location.href);
    if (next == null || next === '') url.searchParams.delete(key);
    else url.searchParams.set(key, next);
    window.history.pushState(null, '', url);
    window.dispatchEvent(new Event('pushstate'));   // pushState doesn't fire popstate, so notify ourselves
  }

  return [value, setValue];
}
import { useSearchParam } from './useSearchParam.js';

export default function App() {
  const [tab, setTab] = useSearchParam('tab');

  return (
    <>
      <button id="profile" onClick={() => setTab('profile')}>Profile</button>
      <button id="billing" onClick={() => setTab('billing')}>Billing</button>
      <p id="out">Current tab: {tab ?? 'none'}</p>
    </>
  );
}

Tested: opening ?tab=profile showed “profile”, clicking Billing changed the URL to ?tab=billing, and the browser’s Back and Forward buttons switched the component between the two correctly.

Best Practices

  • Make the URL the source of truth. Derive filters, sort and page from the query string instead of copying them into component state, so links, refreshes and the back button always agree with the UI.
  • Validate and convert. Everything arrives as a string; parse numbers, enums and booleans with a schema or nuqs parsers, and fall back to defaults for bad input.
  • Reset the page number when search, sort or filters change.
  • Use replace: true for rapid updates such as typing, and normal navigation for deliberate ones such as changing pages.
  • Keep keys short and readable (q, sort, page) and leave out default values to keep URLs clean.
  • Never put secrets or personal data in the URL. Query strings end up in browser history, server logs and analytics.
  • Keep URLs reasonably short. Very long query strings can be rejected by servers, proxies and CDNs.

Query Parameters and SEO

Search engines treat each distinct query string as a separate URL. For filter combinations that create near-duplicate pages, point a canonical tag at the main version. Google no longer uses rel=”next” and rel=”prev” for pagination, so give each paginated page a self-referencing canonical and normal links to the other pages. Content that only appears after client-side JavaScript reads the query string is harder for crawlers, which is another reason to read parameters in loaders or on the server.

Migrating from Older Versions

  • React Router v5: replace query-string or manual location.search parsing with useSearchParams().
  • v6 and v7 to v8: change imports from react-router-dom to react-router (and react-router/dom for RouterProvider). The useSearchParams API is the same. React Router 8 requires React 19.2.7+ and Node 22.22+; see React Router v8 explained for the full upgrade.

Conclusion

Query parameters turn UI state into shareable, bookmarkable URLs. In React Router 8, read them with useSearchParams() or in loaders, write them with setSearchParams() using a callback that copies the previous params, and validate them before use. In Next.js, use the searchParams page prop or useSearchParams() inside Suspense, and in plain React, URLSearchParams plus the History API is enough. Treat the URL as the source of truth and your filters, pagination and search will just work, including the back button.

FAQs

How do I get query params in React?

With React Router, call useSearchParams() and read values with searchParams.get(“key”). In Next.js, use useSearchParams() from next/navigation in client components or the searchParams prop in pages. Without a router, use new URLSearchParams(window.location.search).get(“key”).

How do I update query params without losing the others?

Use the callback form: setSearchParams(prev => { const next = new URLSearchParams(prev); next.set(“sort”, “price”); return next; }). Setting an object replaces the whole query string.

What is the difference between useParams and useSearchParams?

useParams returns the dynamic segments defined in the route path, such as :userId in /users/42. useSearchParams returns the optional key-value pairs after the ?, such as ?page=2.

How do I handle multiple values for one query parameter?

Repeat the key (?tag=desk&tag=cables) and read it with searchParams.getAll(“tag”), or pass an array in the object form: setSearchParams({ tag: [“desk”, “cables”] }). nuqs uses comma-separated values by default.

Why is my query param a string instead of a number?

Everything in a URL is a string. Convert it with Number() and handle invalid values, or validate with a schema such as Zod or nuqs parsers like parseAsInteger.

Should I use encodeURIComponent with URLSearchParams?

No. URLSearchParams encodes and decodes values automatically, so encoding first produces double-encoded values. Use encodeURIComponent only when building a URL string manually.

Does useSearchParams work in React Router v8?

Yes, with the same API as v6 and v7. Import it from “react-router”, since the react-router-dom package was removed in v8.

Snehasish Konger
Developed @scientyficworld.org | Technical writer @Nected | Content Developer
Connect with Snehasish Konger

On This page

Take a Pause with Intervals

A Sunday letter on building, writing, and thinking deeper as a developer — short, honest, and worth your time.

Snehasish Konger profile photo

"Hey there — I'm Snehasish. Hope this post saved you some head-scratching time! I've spent years turning technical chaos into clarity, and I'm here to be your guide through the maze of modern tech. Stick around for more lightbulb moments — we're just getting started."

Related Posts