The Filter That Vanishes When Your Teammate Opens Your Link
An admin team reviews orders. You build the filter row — status buttons plus a pagination control — the obvious way:
'use client';
import { useState } from 'react';
export function OrderFilters() {
const [status, setStatus] = useState('all');
const [page, setPage] = useState(1);
// fetching and filtering happen from those two values
}
It works until the product manager sends a link to a teammate: "look at these three failed orders." The teammate opens it and sees every order on page one, unfiltered. Reloading loses the view. Back does not undo a filter change. The browser cannot bookmark the view you just described in chat. The state was real; it lived in the one store the browser gives you nothing with — one component's memory.
The URL is a different kind of store. Search params are shareable, bookmarkable, survive reload and Back/Forward, and — the reason this matters in a Next.js course — the server reads the very same URL string the client sees. One value serves as the client's view-state container and the server's query input. There is nothing to serialize, nothing to hydrate, and no chance of the two sides disagreeing.
Client side, useSearchParams exposes the current params; writing them means navigating to the same path with a new query string:
// app/orders/status-filter.tsx
'use client';
import { usePathname, useRouter, useSearchParams } from 'next/navigation';
export function StatusFilter() {
const router = useRouter();
const pathname = usePathname();
const searchParams = useSearchParams();
const current = searchParams.get('status') ?? 'all';
function select(status: string) {
const params = new URLSearchParams(searchParams.toString());
if (status === 'all') params.delete('status');
else params.set('status', status);
router.replace(`${pathname}?${params.toString()}`);
}
return (
<div role='group' aria-label='Filter by status'>
{['all', 'paid', 'failed'].map((s) => (
<button key={s} onClick={() => select(s)} aria-pressed={current === s}>
{s}
</button>
))}
</div>
);
}
Two details in that function carry most of the weight. new URLSearchParams(searchParams.toString()) clones before mutating — writing straight into the hook's params drops every other param you did not touch (the page number, the search term), which surfaces later as "filters reset whenever I change the sort." And params.delete('status') when the value is all keeps the canonical URL free of ?status=all; defaults belong outside the URL.
Server side, the page reads the URL through the searchParams prop and turns it into a query:
// app/orders/page.tsx
import { db } from '@/lib/db';
import { OrderFilters } from './status-filter';
import { OrdersTable } from './orders-table';
export default async function OrdersPage({
searchParams,
}: {
searchParams: Promise<{ status?: string; page?: string }>;
}) {
const { status, page } = await searchParams;
const orders = await db.order.findMany({
where: status ? { status } : {},
skip: ((Number(page) ?? 1) - 1) * 25,
take: 25,
});
return (
<main>
<OrderFilters />
<OrdersTable rows={orders} />
</main>
);
}
In Next.js 15, searchParams is a Promise and you await it — in 14 it was a plain object, and that version detail comes up in interviews because it followed React's async request APIs. The page becomes an async function, or hands the awaited object to a child.
Both sides now read one source of truth. Clicking a filter navigates, Next.js fetches the route's RSC payload with the new query, the server component re-runs with the new searchParams, and the table updates. Send the URL to a teammate: their browser requests the same payload with the same params and gets the same three failed orders. This is exactly the failure the last chapter of Local State with useState and useReducer predicted — state that users expect to share has moved out of component memory. What has not been decided yet is what each navigation does to the history stack, which is the next question.
router.push, router.replace, and Link: Three Ways to Change the URL, Three Histories
Filter a list with router.push on every click and the history fills with near-identical entries; the user presses Back five times to undo five filter tweaks and cannot tell which entry is which. Each navigation API writes a different history, and picking the wrong one is what users mean when they say the back button is broken.
| Call | History effect | Reach for it when |
|---|---|---|
router.push(path) |
Adds an entry | The URL is a new view: a detail page, a different search |
router.replace(path) |
Overwrites the current entry | The URL refines the current view: filter, sort, page number |
<Link href> |
Push semantics | Any navigational link — and it gets prefetching for free |
router.back() |
Pops the entry | You are building explicit undo UI |
The rule of thumb: if the URL is a refinement of the view already on screen, replace; if it is a different view, push. A refined view that pushes makes Back walk through five near-identical states — noise the user has to wade through to reach the page they came from. Navigational jumps should additionally be <Link> elements rather than programmatic calls, so they inherit prefetching of the route's RSC payload on hover and viewport entry.
Pagination also wants the scroll behavior disabled. Navigation scrolls to top by default, which throws the user away from the row they were reading the moment they change pages:
const params = new URLSearchParams(searchParams.toString());
params.set('page', String(next));
router.replace(`${pathname}?${params.toString()}`, { scroll: false });
What actually happens after the call is the mechanism interviewers probe. router.replace starts a client-side navigation: Next.js fetches the RSC payload for the route with the new query string, the page server component re-runs on the server with the new searchParams, and React reconciles the returned payload into the existing tree. Client components in that tree stay mounted — their own useState survives the navigation — while the server-rendered slots are swapped for new output. Any client component that reads useSearchParams re-renders because the hook subscribes to navigation, so your filter buttons update their highlighted state without an extra synchronization layer.
Until the fresh payload arrives, the previous view stays on screen. A loading.tsx file (or a <Suspense> boundary around the server-rendered section) swaps in a skeleton the instant navigation starts; without one, a slow query makes the page look frozen and users click the same filter twice, wondering.
There are two readers of the URL and it pays to keep them straight:
| Reader | Runs in | What it sees |
|---|---|---|
useSearchParams() |
Client components | The live params of the current URL |
searchParams prop |
The page (server) | The params of the request this render came from |
They always agree because both derive from the same request URL. Neither one writes. Writing happens only through client-side navigation — the URL changes first and the render follows. That inversion (a state change is a navigation) is why filters built this way inherit Back, Forward, and sharing with no extra code.
One structural note: only the page component receives the searchParams prop. Layouts do not re-render when params change, so a sidebar at the layout level that must react to filters reads useSearchParams in a client component of its own. Route groups, dynamic segments, and parallel slots all keep the same contract — params arrive at the page, and the page decides what to pass down.
Suspense Errors, Dropped Params, and URLs That Outgrow the Browser
The build error: useSearchParams without a Suspense boundary
A statically rendered page whose client filter calls useSearchParams fails the build with:
useSearchParams() should be wrapped in a suspense boundary at page "/orders"
Static generation needs a shell it can render without a request, and a component subscribed to search params is inherently request-dependent. The fix is to let part of the page stay static by wrapping the consumer:
import { Suspense } from 'react';
<Suspense fallback={<FilterSkeleton />}>
<StatusFilter />
</Suspense>
A loading.tsx at the route level satisfies the same requirement. Do not silence the error by forcing the whole route dynamic — that prerenders nothing and pushes every visit onto the server.
Encoding and multi-value filters
Never concatenate query strings by hand: & separates params and most reserved characters must be encoded, so a raw filter value like R&D silently truncates the param and everything after it. Always build through URLSearchParams, which encodes for you. For filters with several active values, repeat the key instead of inventing a delimiter:
params.append('tag', 'eu');
params.append('tag', 'bulk'); // ?tag=eu&tag=bulk
On the server the param may arrive as string | string[], so normalize before use: const tags = Array.isArray(p.tag) ? p.tag : p.tag ? [p.tag] : [].
Treat every param as untrusted input
status came from a URL a user can edit. Even if your ORM parameterizes queries, whitelist before you query — an enum, a zod parse, anything that turns arbitrary strings into a known set:
const ALLOWED = new Set(['paid', 'failed']);
const status = ALLOWED.has(raw) ? raw : undefined;
A value that fails the check falls back to the default (no filter), never to an error page. Keep the check in one exported helper — parseStatus(raw) in a shared module — so the page, the filter UI, and the action's tests all interpret status identically instead of repeating the ALLOWED set in three files.
Defaults and URL length
delete defaults instead of writing them: ?status=all&page=1 pins today's defaults into every shared link forever, so when the default page size changes, old links behave differently from new ones. Keep the URL to the params that actually define the view.
That discipline also defends against the hard limit: browsers cap URLs at roughly 2000 characters. A view like "filter by these 87 ids" does not fit and will not fit. The pattern that does: persist the selection server-side and put only its identity in the URL — ?view=abc123. A URL names a view; it is not a transport for arbitrary payloads.
The stale-copy trap
The last failure is quiet. This compiles and works on first render, then misbehaves:
const [status, setStatus] = useState(searchParams.get('status')); // stale copy
useSearchParams re-renders the component when the user presses Back or Forward, but the value copied into useState on the first render never updates. Read the param during render instead — const status = searchParams.get('status') ?? 'all', as in the first chapter — or force a fresh mount with a key derived from the params. State that describes which view you are looking at belongs in the URL for exactly these reasons; state that describes how the page is being used right now — a modal's open flag, a sidebar's collapsed edge — does not identify a view and belongs in a store, which is where Global Client State with Zustand picks up.
Quiz
1. Every filter click calls router.push. The user clicks three different filters, then presses Back once. What happens?
2. How does a Next.js 15 page component receive the current search params?
3. A filter value 'R&D' is inserted into a query string with template concatenation: `?status=${value}&page=2`. What breaks?
4. The build fails with 'useSearchParams() should be wrapped in a suspense boundary at page "/orders"'. What is the correct fix?
Flashcards
Question
When do you use router.replace instead of router.push for search-param changes?
Click to reveal answer
Answer
When the new URL refines the current view (filter, sort, page number). replace overwrites the current history entry so Back skips the intermediate tweaks; push is for genuinely different views such as a detail page or a new search.
Question
What happens on the server when a client component calls router.replace with new search params?
Click to reveal answer
Answer
Next.js fetches the route's RSC payload for the new query; the page server component re-runs on the server with the new searchParams prop, and React reconciles the fresh payload into the tree. Mounted client components survive; their useSearchParams subscribers re-render.
Question
Why must search params be cloned before modification?
Click to reveal answer
Answer
Because writing into new URLSearchParams(searchParams.toString()) preserves params you are not changing. Setting a param on the hook's params directly drops the others (page, search term), which shows up as filters resetting when the sort changes.
Question
Why are default param values deleted from the URL instead of written into it?
Click to reveal answer
Answer
So shared links stay short and canonical, and so a change to the default itself does not make old links behave differently from new ones. ?status=all pins today's default into every bookmark forever.
Question
Why does useState(searchParams.get('x')) go stale on Back/Forward?
Click to reveal answer
Answer
useState captures the first render's value and never updates it; only the initial value argument is read once. The hook re-renders on navigation but the copied state does not. Read the param during render, or key the component on the params to force a remount.
Revision Notes
Key Takeaways
- 1. View state that users expect to share, bookmark, or recover with Back belongs in search params, not component memory
- 2. The server page and the client hook read the same URL — one source of truth with nothing to serialize or hydrate
- 3. replace for refinements, push for new views: history semantics are a UX decision, not a coin flip
- 4. router.replace triggers an RSC payload fetch; the page re-runs on the server with new searchParams while mounted client components survive
- 5. Next 15 passes searchParams as a Promise, and a useSearchParams consumer must sit under a Suspense boundary or loading.tsx
Interview Tips
- • "Why put filters in the URL instead of state?" — name the four capabilities it buys (share, bookmark, reload, Back/Forward) and add the Next.js-specific one: the server page reads the same params to build its query
- • "push or replace?" — answer with the refinement-vs-new-view rule and the concrete symptom of getting it wrong (Back walking through five filter tweaks)
- • "What happens when search params change?" — walk the mechanism: RSC payload fetch, server component re-runs with new props, client components stay mounted, useSearchParams subscribers re-render
- • "A user reports filters vanish when they press Back" — suspect the stale-copy pattern (useState initialized from searchParams) first, then layouts that swallow param changes; both are placement bugs, not framework bugs
Cheat Sheet
Keeping Filters in the URL
Write side (client component):
'use client';
const searchParams = useSearchParams();
const pathname = usePathname();
const router = useRouter();
function setParam(key: string, value?: string) {
const params = new URLSearchParams(searchParams.toString()); // clone!
if (value) params.set(key, value); else params.delete(key);
router.replace(`${pathname}?${params.toString()}`, { scroll: false });
}
Read side (server page):
export default async function Page({
searchParams,
}: { searchParams: Promise<{ status?: string }> }) {
const { status } = await searchParams; // Next 15: it's a Promise
}
Navigation semantics:
| Refinement of current view | New view | Link |
|---|---|---|
router.replace (no history spam) |
router.push |
<Link> (push + prefetch) |
Rules: clone before mutating · delete defaults · build strings with URLSearchParams · repeat a key for multi-value filters · whitelist param values before querying · wrap useSearchParams consumers in Suspense · read params during render, never copied into useState · URLs name views, not payloads (long selections get a server-saved ?view=id).