skip to content

In React Router v7, how do you render pagination links that change ?page while keeping the product list's other search params?

level: middleimportance: should knowfreq 46%

answer

  1. a search-only to value
  2. copy the current params first
  3. real hrefs for each page
  4. defaults are read, not written

basics

~20 s

Copy the current searchParams, set page on the copy and pass it as a search-only to, like to={?${next}}. React Router keeps the current path and uses exactly the query you built, so category and sort survive.

solid answer

~40 s

A `to` with no pathname, such as `"?page=2"`, resolves against the current URL's pathname, but its query **replaces** the current one. So build it from the current params: `const next = new URLSearchParams(searchParams); next.set("page", String(n));` then `<Link to={`?${next}`}>`. Or pass an object, `{ pathname: "/products", search: `?${next}` }`; the query must go in `search`, since a `?` inside `pathname` throws an invariant error. Links beat buttons calling `setSearchParams` for pagination because each page gets a real `href` that can be opened in a new tab or shared. `createSearchParams` helps when a key has several values. If `useSearchParams` is given defaults, such as `{ sort: "newest" }`, they only fill in reads; they are not written to the URL.

code

tsx · 27 lines
tsx
import { Link, useSearchParams } from "react-router";

export function Pagination({ pageCount }: { pageCount: number }) {
  const [searchParams] = useSearchParams();
  const current = Number(searchParams.get("page") ?? "1");

  const hrefFor = (page: number) => {
    const next = new URLSearchParams(searchParams);
    if (page === 1) next.delete("page");
    else next.set("page", String(page));
    return `?${next}`;
  };

  return (
    <nav aria-label="Pagination">
      {Array.from({ length: pageCount }, (_, i) => i + 1).map((page) => (
        <Link
          key={page}
          to={hrefFor(page)}
          aria-current={page === current ? "page" : undefined}
        >
          {page}
        </Link>
      ))}
    </nav>
  );
}

go deeper

for a junior

Recall that a Link to "?page=2" keeps the path but replaces the whole query, so build the query from the current params.

for a middle

Explain search-only to resolution, the object to shape with its pathname invariant, createSearchParams for arrays, and how defaultInit merges only into reads.

for a senior

Build a shared href helper that preserves filters and canonicalises defaults, and choose links over buttons for shareable, back-navigable pages.

for a principal

Standardise how every list screen serialises its view state into links, so filters, sorting and pagination compose predictably across teams.

## Why links, not buttons In a product list at `/products?category=shoes&sort=price&page=1`, pagination could be buttons that call `setSearchParams`. Links are better: - each page has a real **`href`**, so users can open page 3 in a new tab, copy it or bookmark it; - crawlers and assistive technology see navigation as links; - every page change is a history entry, so Back returns to the previous page of results. React Router's `<Link>` gives you all of that as long as its `to` carries the right query. ## How a search-only `to` resolves A `to` value that contains **no pathname**, only a query and optionally a hash, is resolved against the **current URL's pathname**: | Current URL | `to` | Result | |---|---|---| | `/products?category=shoes&sort=price` | `"?page=2"` | `/products?page=2` | | `/products?category=shoes&sort=price` | `` `?${next}` `` built from current params | `/products?category=shoes&sort=price&page=2` | | `/products?category=shoes` | `"."`, rendered by the products route | `/products` (query cleared) | The first row is the trap: the path is kept, but the query in `to` is the **whole** new query. Nothing is merged. ## Building the query from the current params ```tsx function pageHref(searchParams: URLSearchParams, page: number) { const next = new URLSearchParams(searchParams); // copy if (page === 1) next.delete("page"); else next.set("page", String(page)); return `?${next}`; } ``` 1. **Copy** the params returned by `useSearchParams` so you never mutate the object the component is reading. 2. **Change** only the key the link is about; delete it when it returns to the default, for canonical URLs. 3. **Serialise** with template literal interpolation, which calls `toString()` and percent-encodes values. The same helper works for `navigate(pageHref(searchParams, 3))` when navigation is programmatic. ## Object `to` values and the `?` invariant `to` can also be an object with `pathname`, `search` and `hash`. React Router validates it: a `?` in `pathname` throws an invariant error saying you cannot include a `?` character in a manually specified `to.pathname` field and should move it to `to.search`. A `#` in `pathname` or `search` fails the same way. Strings are parsed for you, so `to="/products?category=shoes"` is fine; objects must be split correctly. `createSearchParams` builds a `URLSearchParams` like the constructor does, but also accepts arrays in the object form, which is convenient for multi-valued keys: `createSearchParams({ brand: ["nike", "reebok"] })` produces `brand=nike&brand=reebok`. ## Defaults with `useSearchParams(defaultInit)` `useSearchParams({ sort: "newest" })` lets the component read `sort` as `"newest"` when the URL does not contain it. Two details matter: - **The URL is not changed.** The docs state the default will not change the URL on the first render; it is only merged into the `searchParams` you read, and only for keys absent from the URL. - **The merge stops after the first `setSearchParams` call.** From then on, the component reads the URL alone. A callback update copies the params it reads, defaults included, into the URL on that first write; an object update does not, so `sort` then reads `null` unless the URL holds it. That makes `defaultInit` a convenience for reads, not a way to canonicalise URLs. Many teams instead apply defaults at read time: `searchParams.get("sort") ?? "newest"`. ## Common mistakes - **`<Link to="?page=2">` in a filtered list**, which silently drops every other filter. - **Putting the query in `pathname`** of an object `to`, which throws during render. - **Buttons for pagination**, losing open-in-new-tab and shareable URLs. - **Expecting defaults in the address bar** after passing `defaultInit`. ## Checking that the view survives reload and Back The point of carrying the view in the query is that three user actions all rebuild it. Test each one explicitly: 1. **Reload** on `/products?category=shoes&sort=price&page=2`: the same filters, sort and page appear, because the component reads everything from the URL. 2. **Back** after clicking page 3: the list returns to page 2, since each pagination link pushed a history entry. 3. **Open in a new tab** from a pagination link: the new tab shows that page with the filters intact, which only works because the link's `href` carries the whole query. If any of these fails, some part of the view is held in component state instead of the URL, or a link is dropping keys. ## Summary - Search-only `to` values keep the path and replace the whole query. - Build hrefs from a copy of the current params. - Keep the query in `search` when `to` is an object. - `defaultInit` fills reads, not the URL, and stops after the first write.

  • In React Router v7, how do you link from another page into the product list with filters already applied?
    Use a full string, such as `<Link to="/products?category=shoes&sort=price">`, which React Router parses into pathname and search, or an object with `pathname: "/products"` and `search` built by `createSearchParams`. The list reads the query on arrival, so the filtered view appears without extra state.
  • In React Router v7, why can useSearchParams({ sort: "newest" }) make sort disappear after the first filter change?
    Defaults are merged into reads only until the first `setSearchParams` call. If that call uses an object without `sort`, the new URL has no `sort` and defaults are no longer merged, so `get("sort")` returns `null`. Apply the default at read time with `?? "newest"` to avoid the surprise.

saying these in an interview costs you the question

  • <Link to="?page=2"> keeps the current filters and only changes page
  • A search-only to value resolves against the root path
  • Putting the query inside to.pathname works the same as using to.search
  • useSearchParams defaults are written into the address bar
  • Pagination should use buttons calling setSearchParams rather than links