searchParams Is Empty in Production but Works in next dev: the Static Rendering Contract
It works perfectly on your machine. /dashboard?userId=42 loads the right user, the filters apply, everything is green. You deploy. The same URL in production renders the page with no user, empty filters, as if the query string were not there. You open the network tab, the query string is right there in the request. So where did it go?
Or you get the build-time version of the same thing, a hard failure that never appears in next dev:
Error: Dynamic server usage: searchParams.userId
Both symptoms are the same underlying fact wearing two different costumes. searchParams is a request-time value, and in production your route was rendered without a request, at build time, as static HTML. This is not a Next.js bug, however much it feels like one. It is the static/dynamic rendering contract doing exactly what it promises, and once you can see the contract, both the empty params and the build error stop being mysterious.
searchParams only has values when the route renders per-request. In production, a statically prerendered route has no request, so params are empty.
- Reading
searchParamsshould mark the route dynamic. If it does not (or you also forced static), you get empty params or a "Dynamic server usage" error. - Make the route dynamic explicitly:
export const dynamic = 'force-dynamic', or in the App Router simplyawaitthe asyncsearchParamsprop. - Do not pair
generateStaticParamswith a route that needs query strings. That asks for static generation, which is the opposite of whatsearchParamsneeds. - With
output: 'export'there is no server at request time at all, so read query params on the client withuseSearchParamsinstead.
Why next dev lies to you here
next dev renders every single request dynamically. There is no build-time prerender in development, so searchParams is always populated and everything you do works. That is exactly why this class of bug is invisible until production: dev is not testing the thing that breaks. The moment the production build runs, Next.js tries to decide, per route, whether it can render your page once at build time and serve that HTML to everyone, or whether it must render fresh on each request.
Static is the default and the fast path. Next.js will statically prerender a route unless something in it demands a request. Accessing searchParams is supposed to be one of those demands. When the signal works, the route flips to dynamic and your params arrive. When the signal is missing or is overridden by something that forces static, the route is prerendered with no request, and searchParams is an empty object, because at build time there genuinely was no query string.
The current App Router model: searchParams is async
Since Next.js 15, searchParams (and params) are passed as a Promise you must await. This matters for this bug because awaiting the prop is itself the dynamic signal. A correct, dynamic page looks like this:
// app/dashboard/page.tsx
export default async function Dashboard({
searchParams,
}: {
searchParams: Promise<{ userId?: string }>;
}) {
const { userId } = await searchParams; // this access marks the route dynamic
const user = userId ? await getUser(userId) : null;
return <UserPanel user={user} />;
}
If you are on an older major version, the prop is a plain object rather than a Promise, but the rendering contract is identical: reading it should opt you into dynamic rendering. If your build instead throws Error: Dynamic server usage: searchParams.userId, something is forcing the route static while you read a request value, and the two cannot both be true.
The fix, in numbered steps
Step 1: Confirm how the route is actually being rendered
Do not guess. The build output tells you per route. Run a production build and read the legend:
next build
Route (app) Size
┌ ○ /dashboard 1.2 kB ○ (Static) prerendered as static content
└ ƒ /profile 0.9 kB ƒ (Dynamic) server-rendered on demand
If /dashboard shows ○ (Static) but it needs the query string, that is your whole problem in one character. It was prerendered, so it has no searchParams. You want it on the ƒ (Dynamic) line.
Step 2: Opt the route into dynamic rendering
The most explicit lever is the route segment config. Add it to the page that needs the query string:
// app/dashboard/page.tsx
export const dynamic = 'force-dynamic';
This tells Next.js: never prerender this route, always render per request. After this, searchParams carries real values in production because there is always a request behind the render. In many cases just awaiting the prop is enough, but force-dynamic is the unambiguous version when you want zero doubt, or when a wrapping layout or a caching setting is muddying the automatic detection.
Step 3: Remove the thing forcing it static
If the route keeps rendering static despite reading searchParams, something is out-voting the dynamic signal. The usual suspects:
generateStaticParamson the same route. This declares "prerender these path variants", which requests static generation. It answers path segments, not query strings, and query strings are not part of it. If your route genuinely needs per-request query params, it should not also be trying to statically enumerate itself. Pick one.export const dynamic = 'error'or'force-static'. These explicitly forbid dynamic rendering, which is what turns asearchParamsread into theDynamic server usagebuild error. Remove or change them.- A parent layout forcing static. Segment config cascades. Check the layouts above your page.
This is the same family of levers I list in every way Next.js opts a route out of static rendering; if you are unsure which one is winning, that reference maps them all.
Step 4: If you are on output: 'export', move to the client
A full static export (output: 'export' in next.config.js) has no server at request time. There is nothing to run force-dynamic on, so server-side searchParams is permanently empty and there is no config that changes that. Read the query string on the client instead:
'use client';
import { useSearchParams } from 'next/navigation';
import { Suspense } from 'react';
function DashboardInner() {
const params = useSearchParams();
const userId = params.get('userId');
return <UserPanel userId={userId} />;
}
export default function Dashboard() {
// useSearchParams must be inside a Suspense boundary or the build errors
return (
<Suspense>
<DashboardInner />
</Suspense>
);
}
The Suspense boundary is not optional; without it a static export build fails. That specific failure has its own write-up in useSearchParams needs a Suspense boundary.
Verification
Prove it two ways. First, the build output now lists the route as dynamic:
next build | grep dashboard
# └ ƒ /dashboard ... ƒ (Dynamic) server-rendered on demand
Second, and this is the check people skip, verify against the production build locally, not next dev, because dev cannot reproduce the bug:
next build && next start
curl -s "http://localhost:3000/dashboard?userId=42" | grep -o "user-42" && echo "params arrived"
If the marker for user 42 is in the server-rendered HTML from next start, the query string is reaching your server render in production mode. That is the real test.
What people get wrong
Assuming it is a bug and filing an issue. It is the documented contract. searchParams requires a request; static prerender has none. Next.js is being consistent, not broken.
Testing only in next dev. Dev renders everything dynamically, so it can never surface this. If a routing or params issue "works locally", rebuild with next build && next start before you believe it.
Reaching for generateStaticParams to "fix" empty params. This is backwards. generateStaticParams makes the route more static, which is the opposite of what you need. It handles dynamic path segments like [id], never query strings.
Sprinkling export const dynamic = 'force-dynamic' across the whole app. Only the routes that actually read request-time data need it. Forcing every route dynamic throws away the performance of static rendering for pages that never needed a request. Be surgical: dynamic where you read the query, static everywhere else.
Confusing searchParams with params. params are the path segments (/user/[id]) and can be statically generated with generateStaticParams. searchParams are the query string and cannot. Mixing them up sends you down the wrong fix entirely.
When it is still broken
- Route is dynamic in the build output but params are still empty. Check for a cached response. A CDN or
fetchcache in front can serve a stale render that predates your change. Purge it and confirm with a cache-busting query. - You added
force-dynamicand now everything is slow. You forced too many routes dynamic. Pull it back to only the pages that read the query string, and let the rest stay static. - The build error persists after removing
generateStaticParams. A parent layout or afetchwithcache: 'force-cache'may still be pinning the segment static. Segment config is inherited; audit upward. - You need the value in metadata.
generateMetadataalso receivessearchParamsas a Promise; the same dynamic contract applies there, so reading it opts the route dynamic just as the page does.
The mental model that survives every version: searchParams is a per-request value, and rendering is static by default. Those two facts are in tension by design, and your job is to tell Next.js, clearly, that this particular route needs the request. Say it once, in the right place, and the query string comes back.
Frequently asked questions
- Why is searchParams empty in production but populated in next dev?
- next dev renders every request dynamically, so searchParams is always available. In production, if the route was statically prerendered at build time, there is no request yet, so searchParams comes back empty. The page is not broken; it was rendered as static HTML that has no per-request query string. You have to opt the route into dynamic rendering for searchParams to carry real values.
- What does 'Dynamic server usage: searchParams' actually mean?
- It means you accessed searchParams during an attempt to statically prerender the route, and Next.js is telling you those two things are incompatible. Reading searchParams requires a real request, and static prerendering happens at build time with no request. The message is Next.js refusing to silently give you empty params; you resolve it by making the route dynamic.
- How do I force a route to be dynamic for searchParams?
- Export const dynamic = 'force-dynamic' from the page, or simply await the searchParams prop, which in the App Router already marks the route dynamic on access. Do not also define generateStaticParams for the same route expecting query strings, because that requests static generation and conflicts with reading searchParams.
- Does output: 'export' support searchParams?
- No. A fully static export produces plain HTML files with no server at request time, so server-side searchParams is always empty there. If you need query parameters with output: 'export', read them on the client with the useSearchParams hook instead, wrapped in a Suspense boundary, because there is no server render to provide them.