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
searchParamson the server as a Promise (Next.js 15+ pattern) - Using
useSearchParamson 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
keyon<Suspense>to re-trigger loading states on param changes
Deep Dive
How It Works
- Server-side
searchParams: In the App Router, page components receivesearchParamsas a prop. In Next.js 15+, this is aPromisethat must be awaited. AccessingsearchParamsopts the route into dynamic rendering. - Client-side
useSearchParams: Returns a read-onlyURLSearchParamsinstance. To update, construct a newURLSearchParams, modify it, and push withrouter.push()orrouter.replace(). useSearchParamsrequires 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.pushcreates a new history entry;router.replacereplaces 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
-
searchParamsopts the route into dynamic rendering -- Any page that readssearchParamson the server cannot be statically generated. Fix: If you want a static page with client-side filtering, read params only in Client Components withuseSearchParams. -
useSearchParamswithout Suspense causes build errors -- During prerendering,useSearchParamsthrows because there are no params to read. Fix: Always wrap components usinguseSearchParamsin a<Suspense>boundary. -
Stale closure with
searchParams-- In a Client Component, thesearchParamsreference fromuseSearchParams()updates on navigation, but closures in event handlers may capture the old value. Fix: ReadsearchParamsat 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: UsesearchParams.getAll("color")for multi-value params. -
searchParamsis a Promise in Next.js 15+ -- Destructuring it directly withoutawaitgives you a Promise object. Fix: Alwaysconst { q } = await searchParamsin Server Components.
Alternatives
| Alternative | Use When | Don't Use When |
|---|---|---|
useState (client-only) | State is ephemeral and does not need to survive refresh | State should be shareable via URL |
Dynamic route segments ([slug]) | The parameter defines the resource identity | The parameter is a filter or modifier |
| Cookies | State should persist across sessions but not appear in the URL | State should be visible and shareable |
nuqs library | You want type-safe, validated search params with serialization | Built-in useSearchParams is sufficient |
| Zustand with URL sync | Complex multi-param state with derived values | Simple key-value URL params |
FAQs
Why is searchParams a Promise in Next.js 15+ Server Components?
- In Next.js 15+,
searchParamsis aPromisethat must be awaited before accessing properties - Destructuring without
awaitgives 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
searchParamsin 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 becauseuseSearchParamsthrows - The Suspense fallback displays while params hydrate on the client
What is the difference between router.push and router.replace for search params?
router.pushcreates a new browser history entry (user can press back)router.replacereplaces the current history entry (better for filters and sorting)- Use
replacewhen 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[], orundefined
What is the stale closure gotcha with useSearchParams?
- Closures in event handlers may capture an old
searchParamsreference - The
searchParamsobject updates on navigation, but the closure does not re-capture it - Read
searchParamsat 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
keyunmounts 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
searchParamswhen 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
Related
- Fetching -- Using search params to drive server-side queries
- Cookies & Headers -- Other ways to access request data
- Client Components -- Where
useSearchParamslives - Static vs Dynamic -- How searchParams affects rendering mode