
Syncing State with URL Search Params in Next.js
Think about the last time you filtered a product list, refreshed the page, and lost every filter you'd set. Or shared a link to a search result and the other person landed on an empty page. Both happen when UI state lives only in useState. The URL is a better home for a lot of that state: it survives reloads, works with the back button, can be bookmarked and shared, and, in the Next.js App Router, can be read directly by Server Components to fetch exactly the right data.
This post shows how to treat URL search params as state in a Next.js 16 app. I'll build a product list with a search box, category filters, sorting, and pagination, where the URL is the single source of truth. Along the way you'll see how to read params on the server, update them from Client Components, debounce input, show pending states, validate untrusted values, and avoid the build errors that useSearchParams can cause.
What Belongs in the URL
Not every piece of state should be in the query string. A good rule of thumb: if a user would expect to get back to the same view by copying the link, the state belongs in the URL.
Good candidates:
- Search queries (
?q=shoes) - Filters (
?category=running&category=trail) - Sort order (
?sort=price-asc) - Pagination (
?page=3) - Selected tab or view mode (
?view=grid) - Date ranges on dashboards
Poor candidates:
- Whether a dropdown or tooltip is open
- Unsaved form drafts
- Anything sensitive (tokens, personal data)
- Large or deeply nested objects that would produce unreadable URLs
Reading Search Params on the Server
In the App Router, a page.tsx receives a searchParams prop. In Next.js 16 it's a promise, and synchronous access has been removed, so you must await it in an async Server Component.
// app/products/page.tsx
type SearchParams = Promise<{ [key: string]: string | string[] | undefined }>;
export default async function ProductsPage({
searchParams,
}: {
searchParams: SearchParams;
}) {
const { q, page } = await searchParams;
return (
<p>
Query: {String(q ?? "")}, page: {String(page ?? "1")}
</p>
);
}
Each value can be a string, a string[] (when the key appears more than once, like ?tag=a&tag=b), or undefined. That type is honest about what users can put in a URL, and it's a reminder that you need to parse these values before using them.
You can also type the page with the generated PageProps helper, for example PageProps<'/products'>, which is available globally after Next.js generates route types.
Two notes on behavior:
- Reading
searchParamsmakes the page render at request time, since the values can't be known at build time. - Layouts don't receive
searchParams. A layout isn't re-rendered on every navigation, so it would show stale values. Read params in the page and pass them down as props.
Parsing and Validating Params
URL input is user input. Someone can type ?page=-4&sort=banana into the address bar. Parse everything into a known shape with defaults before it touches your data layer. Zod makes this concise:
npm install zod
// app/products/search-params.ts
import { z } from "zod";
export const SORT_OPTIONS = ["newest", "price-asc", "price-desc"] as const;
const toArray = (v: unknown) =>
v === undefined ? [] : Array.isArray(v) ? v : [v];
export const productSearchSchema = z.object({
q: z.string().trim().max(100).catch(""),
category: z.preprocess(toArray, z.array(z.string())).catch([]),
sort: z.enum(SORT_OPTIONS).catch("newest"),
page: z.coerce.number().int().min(1).catch(1),
});
export type ProductSearch = z.infer<typeof productSearchSchema>;
export function parseProductSearch(
raw: Record<string, string | string[] | undefined>,
): ProductSearch {
return productSearchSchema.parse(raw);
}
The .catch() calls are the important part. Instead of throwing on bad input, each field falls back to a safe default. ?page=abc becomes page 1, an unknown sort becomes newest, and a single category string is normalized into an array. Your page never has to handle a malformed state.
If you'd rather not add a dependency, a few lines of manual parsing do the same job. The point is to have one function that turns the raw object into a typed, trusted value.
Rendering Data from the URL
Now the page can parse the params and fetch accordingly:
// app/products/page.tsx
import { Suspense } from "react";
import { parseProductSearch, type ProductSearch } from "./search-params";
import { SearchBox } from "./search-box";
import { CategoryFilter } from "./category-filter";
import { SortSelect } from "./sort-select";
import { Pagination } from "./pagination";
import { getProducts } from "@/lib/products";
export default async function ProductsPage({
searchParams,
}: {
searchParams: Promise<{ [key: string]: string | string[] | undefined }>;
}) {
const search = parseProductSearch(await searchParams);
return (
<main>
<h1>Products</h1>
<div className="toolbar">
<SearchBox defaultValue={search.q} />
<CategoryFilter selected={search.category} />
<SortSelect value={search.sort} />
</div>
<Suspense
key={JSON.stringify(search)}
fallback={<p>Loading products...</p>}
>
<ProductResults search={search} />
</Suspense>
</main>
);
}
async function ProductResults({ search }: { search: ProductSearch }) {
const { items, totalPages } = await getProducts(search);
if (items.length === 0) {
return <p>No products match your filters.</p>;
}
return (
<>
<ul>
{items.map((p) => (
<li key={p.id}>
{p.name}, ${p.price}
</li>
))}
</ul>
<Pagination page={search.page} totalPages={totalPages} />
</>
);
}
getProducts is your own data function (a database query, an API call). It receives an already validated object, so it can build a query without defensive checks.
The key on Suspense matters. Without it, React keeps showing the old results while the new ones load, because the boundary has already resolved once. Changing the key when the search changes tells React to treat it as a new boundary and show the fallback again. Whether you want that is a UX choice: some apps prefer to keep stale results visible with a subtle pending indicator, which the next sections show how to do.
Updating the URL from Client Components
Reading is done on the server. Writing happens in Client Components, using three hooks from next/navigation:
useSearchParams()returns a read-onlyURLSearchParamsfor the current URL.usePathname()returns the current path without the query string.useRouter()gives youpushandreplaceto navigate.
Since every control in the toolbar needs to update one key while preserving the others, it's worth writing a small hook once:
// app/products/use-update-search-params.ts
"use client";
import { useTransition } from "react";
import { usePathname, useRouter, useSearchParams } from "next/navigation";
type Updates = Record<string, string | string[] | null>;
export function useUpdateSearchParams() {
const router = useRouter();
const pathname = usePathname();
const searchParams = useSearchParams();
const [isPending, startTransition] = useTransition();
function update(updates: Updates, { resetPage = true } = {}) {
const params = new URLSearchParams(searchParams.toString());
for (const [key, value] of Object.entries(updates)) {
params.delete(key);
if (value === null || value === "") continue;
if (Array.isArray(value)) {
value.forEach((v) => params.append(key, v));
} else {
params.set(key, value);
}
}
if (resetPage) params.delete("page");
const query = params.toString();
startTransition(() => {
router.replace(query ? `${pathname}?${query}` : pathname, {
scroll: false,
});
});
}
return { update, isPending };
}
Here's what each piece does:
- It copies the current params into a mutable
URLSearchParams, because the object fromuseSearchParamsis read-only. - Passing
nullor an empty string removes a key, which keeps URLs clean instead of leaving?q=behind. - Arrays are written with
append, producing repeated keys (?category=a&category=b), which the server-side schema already handles. - Changing a filter resets pagination by default. Staying on page 7 after narrowing results to 2 pages is a classic bug.
- The navigation runs inside
startTransition, soisPendingistrueuntil the new Server Component output arrives. That lets you show a loading state without aSuspensefallback. scroll: falsestops the page from jumping to the top every time a filter changes.
push or replace?
router.push adds a history entry; router.replace overwrites the current one. For a search box that updates as you type, replace is the right choice, otherwise every keystroke becomes a step in the back button. For discrete, deliberate actions like changing pages, push often feels more natural, because users expect Back to return to the previous page of results. The hook above defaults to replace; you could add an option to switch.
A Debounced Search Box
Updating the URL on every keystroke would send a server request per character. Debounce it:
// app/products/search-box.tsx
"use client";
import { useEffect, useRef, useState } from "react";
import { useUpdateSearchParams } from "./use-update-search-params";
export function SearchBox({ defaultValue }: { defaultValue: string }) {
const [value, setValue] = useState(defaultValue);
const { update, isPending } = useUpdateSearchParams();
const timeout = useRef<ReturnType<typeof setTimeout> | null>(null);
useEffect(() => {
return () => {
if (timeout.current) clearTimeout(timeout.current);
};
}, []);
function onChange(next: string) {
setValue(next);
if (timeout.current) clearTimeout(timeout.current);
timeout.current = setTimeout(() => {
update({ q: next.trim() || null });
}, 300);
}
return (
<label>
<span className="sr-only">Search products</span>
<input
type="search"
value={value}
onChange={(e) => onChange(e.target.value)}
placeholder="Search products..."
aria-busy={isPending}
/>
</label>
);
}
The input keeps its own local state so typing stays instant, and only the debounced value is written to the URL. The initial value comes from the server via defaultValue, so a shared link like /products?q=boots renders the box pre-filled.
There's one subtle case: if the user clicks Back and the URL's q changes, the input won't update, because local state was initialized once. If that matters for your app, pass key={search.q} when rendering SearchBox in the page so React remounts it with the new value when the URL changes from outside.
Multi-Select Filters
Checkbox filters map naturally onto repeated keys:
// app/products/category-filter.tsx
"use client";
import { useUpdateSearchParams } from "./use-update-search-params";
const CATEGORIES = ["running", "trail", "hiking", "casual"];
export function CategoryFilter({ selected }: { selected: string[] }) {
const { update, isPending } = useUpdateSearchParams();
function toggle(category: string) {
const next = selected.includes(category)
? selected.filter((c) => c !== category)
: [...selected, category];
update({ category: next });
}
return (
<fieldset disabled={isPending}>
<legend>Category</legend>
{CATEGORIES.map((c) => (
<label key={c}>
<input
type="checkbox"
checked={selected.includes(c)}
onChange={() => toggle(c)}
/>
{c}
</label>
))}
</fieldset>
);
}
The component is controlled by the selected prop that came from the server-parsed URL. After update runs and the navigation completes, the page re-renders with the new selected array. There's no local copy of the filter state to drift out of sync.
Sorting with a Select
// app/products/sort-select.tsx
"use client";
import { SORT_OPTIONS } from "./search-params";
import { useUpdateSearchParams } from "./use-update-search-params";
const LABELS: Record<(typeof SORT_OPTIONS)[number], string> = {
newest: "Newest",
"price-asc": "Price: low to high",
"price-desc": "Price: high to low",
};
export function SortSelect({
value,
}: {
value: (typeof SORT_OPTIONS)[number];
}) {
const { update } = useUpdateSearchParams();
return (
<select
value={value}
onChange={(e) =>
update({ sort: e.target.value === "newest" ? null : e.target.value })
}
aria-label="Sort products"
>
{SORT_OPTIONS.map((o) => (
<option key={o} value={o}>
{LABELS[o]}
</option>
))}
</select>
);
}
Selecting the default sort removes the key entirely. That keeps the canonical URL for the default view as plain /products, which is better for caching and for SEO.
Pagination with Links
Pagination doesn't need JavaScript state at all. Plain Link components with computed href values work, get prefetched, and remain usable before hydration:
// app/products/pagination.tsx
"use client";
import Link from "next/link";
import { usePathname, useSearchParams } from "next/navigation";
export function Pagination({
page,
totalPages,
}: {
page: number;
totalPages: number;
}) {
const pathname = usePathname();
const searchParams = useSearchParams();
function hrefFor(target: number) {
const params = new URLSearchParams(searchParams.toString());
if (target <= 1) params.delete("page");
else params.set("page", String(target));
const query = params.toString();
return query ? `${pathname}?${query}` : pathname;
}
return (
<nav aria-label="Pagination">
{page > 1 && <Link href={hrefFor(page - 1)}>Previous</Link>}
<span>
Page {page} of {totalPages}
</span>
{page < totalPages && <Link href={hrefFor(page + 1)}>Next</Link>}
</nav>
);
}
Link uses push by default, so each page becomes a history entry, which matches what users expect. If you want pagination to work without any client JavaScript, make this a Server Component and build the href from the parsed search object instead of useSearchParams. For a deeper look at list patterns, see building infinite scroll and paginated lists in Next.js.
The Suspense Requirement for useSearchParams
useSearchParams has a build-time catch. If a route is prerendered, Next.js can't know the query string at build time. A Client Component that calls useSearchParams causes the tree up to the nearest Suspense boundary to render on the client instead. If there's no boundary, the production build fails with a "Missing Suspense boundary with useSearchParams" error. In development, routes render on demand, so the problem doesn't show up until you run next build.
In the products page above this doesn't happen, because awaiting searchParams in the page already makes it render per request. It bites when you use useSearchParams in a component on an otherwise static page, such as a search box in a shared header. The fix is to wrap that component:
// app/layout.tsx
import { Suspense, type ReactNode } from "react";
import { HeaderSearch } from "./header-search";
export default function RootLayout({ children }: { children: ReactNode }) {
return (
<html lang="en">
<body>
<header>
<Suspense fallback={<div className="search-placeholder" />}>
<HeaderSearch />
</Suspense>
</header>
{children}
</body>
</html>
);
}
The fallback is what ends up in the prerendered HTML, and HeaderSearch takes over in the browser. Make the fallback the same size as the real control to avoid layout shift.
If your app enables cacheComponents, the same principle applies more broadly: reading request-time data like searchParams outside a Suspense boundary blocks prerendering, and Next.js will tell you so. Keep the await searchParams as close as possible to the components that need it.
Client-Only URL State with the History API
Sometimes the URL state only affects the client, like toggling a grid or list view over data that's already loaded. Going through router.replace triggers a server round trip you don't need. Next.js integrates the native window.history.pushState and replaceState with its router, so useSearchParams and usePathname stay in sync:
// app/products/view-toggle.tsx
"use client";
import { useSearchParams } from "next/navigation";
export function ViewToggle() {
const searchParams = useSearchParams();
const view = searchParams.get("view") === "list" ? "list" : "grid";
function setView(next: "grid" | "list") {
const params = new URLSearchParams(searchParams.toString());
if (next === "grid") params.delete("view");
else params.set("view", next);
const query = params.toString();
window.history.replaceState(
null,
"",
query ? `?${query}` : window.location.pathname,
);
}
return (
<div role="group" aria-label="View mode">
<button aria-pressed={view === "grid"} onClick={() => setView("grid")}>
Grid
</button>
<button aria-pressed={view === "list"} onClick={() => setView("list")}>
List
</button>
</div>
);
}
The key difference: this doesn't re-render Server Components, so the page's searchParams prop doesn't change. Use the History API only for state that Client Components read via useSearchParams. Anything that changes what the server fetches must go through the router.
Should You Use a Library?
For a handful of params, the hook above is enough. If you have many URL-bound values with types (numbers, booleans, dates, JSON), the nuqs library provides a useState-like API (useQueryState) with parsers, defaults, throttling, and an option to trigger server re-renders. It's a good fit for dashboards with lots of controls. Start with plain URLSearchParams and reach for it when the boilerplate starts to hurt.
Common Pitfalls
Building URLs by string concatenation. "?q=" + value breaks on spaces, &, and non-ASCII text. URLSearchParams handles encoding for you.
Forgetting to reset the page. Any change that alters the result set should drop page.
Trusting params without validation. Always parse into a known shape with defaults.
Duplicating URL state in useState. Read from the URL, write to the URL. Only keep local state for in-progress input like the debounced search box.
Leaving empty keys behind. Delete keys when they hold the default value so URLs stay short and canonical.
Conclusion
Treating search params as state gives you shareable, refresh-proof, back-button-friendly UI with very little code. Read and validate searchParams in the page, pass typed values down as props, and update the URL from Client Components with useSearchParams, usePathname, and useRouter. Wrap navigations in startTransition for pending states, debounce text input, use Link for pagination, and keep components that call useSearchParams inside a Suspense boundary on static routes.
Once the URL is your source of truth, a whole category of sync bugs simply goes away, because there's nothing left to sync.


