Skip to the solution
MediumFrontend8 min read

How do you implement search, filtering and pagination with URL search params in the App Router?

1.1k views

The problem

Question presented to candidate: "We have a product list page with a search box, category filters and pagination. Product wants every filtered view to be shareable as a link and to survive a refresh. How would you build that in the App Router?"

What a strong answer should cover:

  • The URL is the state: ?q=lamp&category=desks&page=2 is the single source of truth, so links, refresh, back/forward and sharing all work for free.
  • The page (a Server Component) reads the searchParams prop (a Promise since Next.js 15), validates it, and queries the data on the server; the route becomes dynamic.
  • A small Client Component writes to the URL with useSearchParams + useRouter().replace() (debounced), wrapped in <Suspense>; pagination and filters can be plain <Link>s with a built query string.
  • Treat search params as untrusted input: arrays (string | string[]), invalid numbers, unknown values, out-of-range pages; reset page when the query changes.
  • replace vs push (history entries), useTransition for pending UI, and that each URL change re-renders the page on the server.

Clarifying questions expected:

  • "Should typing in the search box create back-button history entries, or only page changes?" β€” decides replace vs push.
  • "How big is the dataset?" β€” decides server-side filtering (searchParams prop) vs filtering an already-loaded list in the client.

Code / implementation expected: Yes β€” a Server Component page that parses searchParams, a debounced client search box, and pagination links.

  • nextjs
  • search-params
  • pagination
  • app-router

Try it yourself

Sketch your approach before you read the solution. That is what the interview tests.

Stuck? Get a hint.

A short nudge toward the approach. It will not give the answer away.

Solution

The solution is hidden so you can try first

8 min read Β· 8 sections

Read the code

Server page: parse searchParams and paginate
// lib/search-params.ts
// Turn untrusted URL input into a typed, validated query object.
export type RawSearchParams = Record<string, string | string[] | undefined>;
export type ProductQuery = { q: string; categories: string[]; sort: "price" | "name"; page: number };

const first = (v: string | string[] | undefined) => (Array.isArray(v) ? v[0] : v);
const all = (v: string | string[] | undefined) => (v === undefined ? [] : Array.isArray(v) ? v : [v]);
const CATEGORIES = new Set(["lamps", "chairs", "desks"]);

export function parseProductQuery(sp: RawSearchParams): ProductQuery {
  const q = (first(sp.q) ?? "").trim().slice(0, 100);
  const categories = all(sp.category).filter((c) => CATEGORIES.has(c));
  const sort = first(sp.sort) === "price" ? "price" : "name";
  const n = Number.parseInt(first(sp.page) ?? "1", 10);
  const page = Number.isFinite(n) && n > 0 ? n : 1;
  return { q, categories, sort, page };
}

// Build a URL for the same list with some keys changed; null removes a key.
export function withParams(current: URLSearchParams | RawSearchParams, changes: Record<string, string | null>) {
  const next = current instanceof URLSearchParams ? new URLSearchParams(current) : toURLSearchParams(current);
  for (const [k, v] of Object.entries(changes)) (v === null ? next.delete(k) : next.set(k, v));
  const s = next.toString();
  return s ? `?${s}` : "?";
}

function toURLSearchParams(sp: RawSearchParams) {
  const u = new URLSearchParams();
  for (const [k, v] of Object.entries(sp)) for (const x of all(v)) u.append(k, x);
  return u;
}

// app/products/page.tsx
import Link from "next/link";
import { Suspense } from "react";
import { findProducts } from "@/lib/products";
import { parseProductQuery, withParams } from "@/lib/search-params";
import SearchBox from "./search-box";

export default async function ProductsPage({ searchParams }: PageProps<"/products">) {
  const raw = await searchParams;
  const query = parseProductQuery(raw);
  const { items, total, page, pageCount } = await findProducts({ ...query, perPage: 10 });

  return (
    <main>
      <h1>Products</h1>
      <Suspense fallback={<input placeholder="Search products" disabled />}>
        <SearchBox />
      </Suspense>
      <p id="summary">
        {total} results, page {page} of {pageCount}, q=&quot;{query.q}&quot;, categories={query.categories.join("+") || "all"}, sort={query.sort}
      </p>
      <ul>
        {items.map((p) => (
          <li key={p.id}>{p.name} ({p.category}) ${p.price}</li>
        ))}
      </ul>
      <nav aria-label="pagination">
        {page > 1 && <Link href={withParams(raw, { page: String(page - 1) })}>Previous</Link>}{" "}
        {page < pageCount && <Link href={withParams(raw, { page: String(page + 1) })}>Next</Link>}
      </nav>
    </main>
  );
}
Client search box: debounced router.replace
// app/products/search-box.tsx
"use client";
import { usePathname, useRouter, useSearchParams } from "next/navigation";
import { useEffect, useRef, useState, useTransition } from "react";

export default function SearchBox() {
  const searchParams = useSearchParams();
  const pathname = usePathname();
  const { replace } = useRouter();
  const [value, setValue] = useState(searchParams.get("q") ?? "");
  const [isPending, startTransition] = useTransition();
  const timer = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);

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

  function onChange(next: string) {
    setValue(next);
    clearTimeout(timer.current);
    timer.current = setTimeout(() => {
      const params = new URLSearchParams(searchParams);
      if (next) params.set("q", next);
      else params.delete("q");
      params.delete("page"); // a new search starts at page 1
      startTransition(() => replace(`${pathname}?${params.toString()}`, { scroll: false }));
    }, 300);
  }

  return (
    <label>
      Search{" "}
      <input value={value} onChange={(e) => onChange(e.target.value)} placeholder="Search products" />
      {isPending && <span id="searching"> updating...</span>}
    </label>
  );
}

Discussion

Other approaches, follow-ups and how it went in real interviews.

Sign in to join the discussion.

No comments yet. Share how you would answer it.

Question 25 of 129 in Next.js

Keep your momentum going.

Mark this question solved to track your progress, then take the next one while it is fresh.

All Next.js questions
Up nextMedium

Server Components and external packages β€” what does `serverComponentsExternalPackages` do?

Continue