Loading…
Loading…
Reading searchParams on the server is the fastest way to turn a prerendered page into an on-demand one. Here is how to keep both.

Every list page eventually grows a filter. This blog filters by tag through ?tag=react; the algorithms catalog filters by group through ?group=sorting. The obvious move in the App Router is to accept searchParams in the page component and filter on the server.
The filter works. The whole page gets slower, and the route table printed after a build is where you find out why.
searchParams only exists once a real request arrives. A page component that touches it cannot be built to HTML ahead of time, because at build time there is no request to read. The route falls back to on-demand rendering, and the marker in front of its name changes to say so:
○ (Static) prerendered as static content
● (SSG) prerendered as static HTML (uses generateStaticParams)
ƒ (Dynamic) server-rendered on demand
From ● to ƒ. For a catalog page that is the difference between shipping a file that already exists and opening a database connection every time somebody clicks a link in the navbar. This table prints after every build, and it is the single most useful thing to glance at before deploying.
The first: drop searchParams and reach for the client hook useSearchParams(). That hook forces the entire component tree up to the nearest Suspense boundary to render on the client. The route keeps its static marker, but the content inside it disappears from the HTML. Search engines receive an empty shell, and readers get a blank beat before the list appears. You have traded a performance problem for an indexing problem.
The second: read window.location.search inside a useEffect and call setState. This runs. The React Compiler lint rules reject it, because it schedules a second render immediately after the first, and switching the rule off does not remove the flash it warns about.
The blessed way to read browser-only state in React is useSyncExternalStore. It takes three arguments, and the third one is what matters here: a function returning the value to use while rendering on the server.
function subscribe(callback) {
// Back/forward changes the query string without remounting.
window.addEventListener("popstate", callback);
return () => window.removeEventListener("popstate", callback);
}
const getSnapshot = () => window.location.search;
const getServerSnapshot = () => "";
export function useUrlSearch() {
return useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot);
}
export function useUrlParam(key) {
return new URLSearchParams(useUrlSearch()).get(key);
}
The server returns an empty string. The first client paint returns an empty string too. The two agree, so there is no hydration mismatch, and the hook then swaps to the live value and re-renders. The part that pays for itself: the component is still server-rendered, so its content is still sitting in the static HTML.
subscribe listens for popstate because that is the event fired when somebody hits back or forward. Without it, the address bar changes and the list stubbornly does not.
There is a real trade-off, and it lives in that getServerSnapshot returning an empty string: the static HTML is always the unfiltered list. Open /blog?tag=react directly and you see every post for one frame, then it narrows to the React ones once the JavaScript runs.
For a filter that trade is worth taking. The full list is a valid state, the reader sees it for an instant, and the page tightens around their choice. For other things it is a bad trade. Do not reach for this on a password reset page keyed by ?token=, on a cart, or on any page whose main content is meaningless without the query parameter. Those want real dynamic rendering, and ƒ is the correct marker for them.
One constraint travels with the pattern, and I forgot it the first time. If you read the URL on the client, you have to write it on the client as well. Calling router.push when somebody picks a tag is a real navigation, and it throws away the very static page you went to this trouble to keep. Use history.replaceState instead: the link stays shareable and the page stays put.
No comments yet — be the first!