React SME Cookbook
All FAQs

Search Documentation

Search across all documentation pages

search-paramsuseSearchParamsURL-statequery-stringnavigation

Search Params

Read and manipulate URL query parameters with useSearchParams (client) and the searchParams prop (server).

Recipe

Quick-reference recipe card -- copy-paste ready.

// Server Component -- access searchParams as a prop (Promise in Next.js 15+)
type Props = { searchParams: Promise<{ q?: string; page?: string }> };
 
export default async function Page({ searchParams }: Props) {
  const { q, page } = await searchParams;
  const results = await search(q, Number(page) || 1);
  return <ResultsList results={results} />;
}
// Client Component -- useSearchParams hook
"use client";
import { useSearchParams, useRouter, usePathname } from "next/navigation";
 
export function SearchInput() {
  const searchParams = useSearchParams();
  const router = useRouter();
  const pathname = usePathname();
 
  function handleSearch(term: string) {
    const params = new URLSearchParams(searchParams.toString());
    if (term) params.set("q", term);
    else params.delete("q");
    router.push(`${pathname}?${params.toString()}`);
  }
 
  return (
    <input
      defaultValue={searchParams.get("q") ?? ""}
      onChange={(e) => handleSearch(e.target.value)}
      placeholder="Search..."
    />
  );
}

When to reach for this: You need URL-driven state -- search filters, pagination, sorting -- that should be shareable via URL and survive page refreshes.

Working Example

// app/products/page.tsx (Server Component)
import { Suspense } from "react";
import { SearchBar } from "./search-bar";
import { ProductGrid } from "./product-grid";
import { Pagination } from "./pagination";
 
type Props = {
  searchParams: Promise<{
    q?: string;
    category?: string;
    sort?: string;
    page?: string;
  }>;
};
 
export default async function ProductsPage({ searchParams }: Props) {
  const { q, category, sort, page } = await searchParams;
 
  return (
    <main className="max-w-6xl mx-auto p-6">
      <h1 className="text-2xl font-bold mb-6">Products</h1>
 
      <Suspense fallback={<div>Loading search...</div>}>
        <SearchBar />
      </Suspense>
 
      <Suspense
        key={`${q}-${category}-${sort}-${page}`}
        fallback={<div>Loading products...</div>}
      >
        <ProductResults
          query={q}
          category={category}
          sort={sort ?? "newest"}
          page={Number(page) || 1}
        />
      </Suspense>
    </main>
  );
}
 
async function ProductResults({
  query,
  category,
  sort,
  page,
}: {
  query?: string;
  category?: string;
  sort: string;
  page: number;
}) {
  const { products, totalPages } = await fetchProducts({
    query,
    category,
    sort,
    page,
  });
 
  return (
    <>
      <ProductGrid products={products} />
      <Pagination currentPage={page} totalPages={totalPages} />
    </>
  );
}
// app/products/search-bar.tsx
"use client";
 
import { useSearchParams, usePathname, useRouter } from "next/navigation";
import { useTransition } from "react";
import { useDebouncedCallback } from "use-debounce";
 
export function SearchBar() {
  const searchParams = useSearchParams();
  const pathname = usePathname();
  const router = useRouter();
  const [isPending, startTransition] = useTransition();
 
  const handleSearch = useDebouncedCallback((term: string) => {
    const params = new URLSearchParams(searchParams.toString());
    if (term) {
      params.set("q", term);
      params.set("page", "1"); // reset to page 1 on new search
    } else {
      params.delete("q");
    }
 
    startTransition(() => {
      router.push(`${pathname}?${params.toString()}`);
    });
  }, 300);
 
  return (
    <div className="relative mb-6">
      <input
        type="search"
        defaultValue={searchParams.get("q") ?? ""}
        onChange={(e) => handleSearch(e.target.value)}
        placeholder="Search products..."
        className="w-full border rounded px-4 py-2"
      />
      {isPending && (
        <span className="absolute right-3 top-2.5 text-gray-400">
          Searching...
        </span>
      )}
    </div>
  );
}
// app/products/pagination.tsx
"use client";
 
import { useSearchParams, usePathname } from "next/navigation";
import Link from "next/link";
 
export function Pagination({
  currentPage,
  totalPages,
}: {
  currentPage: number;
  totalPages: number;
}) {
  const searchParams = useSearchParams();
  const pathname = usePathname();
 
  function createPageUrl(page: number) {
    const params = new URLSearchParams(searchParams.toString());
    params.set("page", page.toString());
    return `${pathname}?${params.toString()}`;
  }
 
  return (
    <div className="flex gap-2 mt-6">
      {currentPage > 1 && (
        <Link href={createPageUrl(currentPage - 1)} className="px-3 py-1 border rounded">
          Previous
        </Link>
      )}
      <span className="px-3 py-1">
        Page {currentPage} of {totalPages}
      </span>
      {currentPage < totalPages && (
        <Link href={createPageUrl(currentPage + 1)} className="px-3 py-1 border rounded">
          Next
        </Link>
      )}
    </div>
  );
}

What this demonstrates:

  • Reading searchParams on the server as a Promise (Next.js 15+ pattern)
  • Using useSearchParams on the client to read and update query parameters
  • Debouncing search input to avoid excessive navigations
  • Building pagination URLs that preserve existing search params
  • Using a key on <Suspense> to re-trigger loading states on param changes

Deep Dive

How It Works

  • Server-side searchParams: In the App Router, page components receive searchParams as a prop. In Next.js 15+, this is a Promise that must be awaited. Accessing searchParams opts the route into dynamic rendering.
  • Client-side useSearchParams: Returns a read-only URLSearchParams instance. To update, construct a new URLSearchParams, modify it, and push with router.push() or router.replace().
  • useSearchParams requires a Suspense boundary -- During static prerendering, search params are not available. Wrapping the component in <Suspense> provides a fallback while params hydrate on the client.
  • URL state is part of the browser history stack. Using router.push creates a new history entry; router.replace replaces the current entry (better for filters and sorting).

Variations

Using router.replace to avoid polluting history:

// Good for filters -- user can still use back button meaningfully
startTransition(() => {
  router.replace(`${pathname}?${params.toString()}`);
});

Reading a single param with a default:

const sort = searchParams.get("sort") ?? "newest";
const page = Number(searchParams.get("page")) || 1;

Multi-value params (arrays):

// URL: ?color=red&color=blue
const colors = searchParams.getAll("color"); // ["red", "blue"]
 
// Setting multiple values
const params = new URLSearchParams();
["red", "blue"].forEach((c) => params.append("color", c));

TypeScript Notes

// Server Component searchParams type (Next.js 15+)
type PageProps = {
  params: Promise<{ slug: string }>;
  searchParams: Promise<{ [key: string]: string | string[] | undefined }>;
};
 
// Typed search params helper
type ProductFilters = {
  q?: string;
  category?: string;
  sort?: "newest" | "price-asc" | "price-desc";
  page?: string;
};
 
type Props = { searchParams: Promise<ProductFilters> };

Gotchas

  • searchParams opts the route into dynamic rendering -- Any page that reads searchParams on the server cannot be statically generated. Fix: If you want a static page with client-side filtering, read params only in Client Components with useSearchParams.

  • useSearchParams without Suspense causes build errors -- During prerendering, useSearchParams throws because there are no params to read. Fix: Always wrap components using useSearchParams in a <Suspense> boundary.

  • Stale closure with searchParams -- In a Client Component, the searchParams reference from useSearchParams() updates on navigation, but closures in event handlers may capture the old value. Fix: Read searchParams at call time inside the handler, not outside it.

  • Array params lose type safety -- searchParams.get("color") returns only the first value even when multiple exist. Fix: Use searchParams.getAll("color") for multi-value params.

  • searchParams is a Promise in Next.js 15+ -- Destructuring it directly without await gives you a Promise object. Fix: Always const { q } = await searchParams in Server Components.

Alternatives

AlternativeUse WhenDon't Use When
useState (client-only)State is ephemeral and does not need to survive refreshState should be shareable via URL
Dynamic route segments ([slug])The parameter defines the resource identityThe parameter is a filter or modifier
CookiesState should persist across sessions but not appear in the URLState should be visible and shareable
nuqs libraryYou want type-safe, validated search params with serializationBuilt-in useSearchParams is sufficient
Zustand with URL syncComplex multi-param state with derived valuesSimple key-value URL params

FAQs

Why is searchParams a Promise in Next.js 15+ Server Components?
  • In Next.js 15+, searchParams is a Promise that must be awaited before accessing properties
  • Destructuring without await gives you a Promise object, not the values
  • This aligns with the async Server Component model
Does reading searchParams on the server prevent static generation?
  • Yes, accessing searchParams in a Server Component opts the route into dynamic rendering
  • The page cannot be prerendered because search params are only known at request time
  • If you want a static page, read params only in Client Components with useSearchParams
Why does useSearchParams require a Suspense boundary?
  • During static prerendering, search params are not available
  • Without a <Suspense> boundary, the build fails because useSearchParams throws
  • The Suspense fallback displays while params hydrate on the client
What is the difference between router.push and router.replace for search params?
  • router.push creates a new browser history entry (user can press back)
  • router.replace replaces the current history entry (better for filters and sorting)
  • Use replace when frequent param changes would pollute the history stack
How do you handle multi-value search params (e.g., ?color=red&color=blue)?
// Reading multiple values
const colors = searchParams.getAll("color"); // ["red", "blue"]
 
// Setting multiple values
const params = new URLSearchParams();
["red", "blue"].forEach((c) => params.append("color", c));
  • searchParams.get("color") only returns the first value
How do you reset pagination to page 1 when the search query changes?
const handleSearch = (term: string) => {
  const params = new URLSearchParams(searchParams.toString());
  if (term) {
    params.set("q", term);
    params.set("page", "1"); // reset to page 1
  } else {
    params.delete("q");
  }
  router.push(`${pathname}?${params.toString()}`);
};
How do you type searchParams for a Server Component page in TypeScript?
type PageProps = {
  params: Promise<{ slug: string }>;
  searchParams: Promise<{
    [key: string]: string | string[] | undefined;
  }>;
};
  • Values can be string, string[], or undefined
What is the stale closure gotcha with useSearchParams?
  • Closures in event handlers may capture an old searchParams reference
  • The searchParams object updates on navigation, but the closure does not re-capture it
  • Read searchParams at call time inside the handler to get the latest value
How do you use a Suspense key to re-trigger loading states when params change?
<Suspense
  key={`${q}-${category}-${sort}-${page}`}
  fallback={<div>Loading...</div>}
>
  <ProductResults query={q} sort={sort} page={page} />
</Suspense>
  • Changing the key unmounts and remounts the Suspense boundary, showing the fallback again
When should you use searchParams versus dynamic route segments like [slug]?
  • Use dynamic segments ([slug]) when the parameter defines the resource identity (e.g., /products/shoes)
  • Use searchParams when the parameter is a filter or modifier (e.g., ?sort=price&page=2)
  • Search params are optional; dynamic segments are required parts of the URL
How do you create a typed search params helper for better type safety?
type ProductFilters = {
  q?: string;
  category?: string;
  sort?: "newest" | "price-asc" | "price-desc";
  page?: string;
};
 
type Props = { searchParams: Promise<ProductFilters> };
  • This narrows the possible values and gives autocomplete in your IDE