useSearchParams() Should Be Wrapped in a Suspense Boundary: The Error Only Your Build Sees
The feature works. You clicked through it locally for twenty minutes. next dev never said a word. Then you push, the Docker build runs in CI, and the whole pipeline dies on a route you have not touched in three weeks.
This is the single most common way a Next.js App Router team discovers that next dev and next build are not running the same code path. The dev server renders everything on demand. The build tries to prerender your pages into static HTML, and prerendering is where useSearchParams() becomes a problem, because search parameters do not exist until there is a request.
The fix is small. The reason it took you two hours to find is that the error names a route, not a component, and the component is usually three levels down inside a shared header.
move the component that calls useSearchParams() into its own client component and render it inside a <Suspense> boundary. The boundary must be a parent of the component that calls the hook, not in the same component.
- Run
next build --debug-prerenderto get an unminified stack trace pointing at the actual component. - Wrap the smallest possible subtree, so the rest of the page stays statically prerendered.
- If the route is genuinely dynamic anyway, call
await connection()in the server component page instead. - Run
next buildlocally before you trust a greennext dev.
The exact error: useSearchParams() should be wrapped in a suspense boundary
The build output looks like this, usually buried after a wall of route symbols:
Error occurred prerendering page "/dashboard". Read more: https://nextjs.org/docs/messages/prerender-error
useSearchParams() should be wrapped in a suspense boundary at page "/dashboard". Read more: https://nextjs.org/docs/messages/missing-suspense-with-csr-bailout
at c (/app/.next/server/chunks/8231.js:1:4021)
Export encountered errors on following paths:
/dashboard/page: /dashboard
Two details matter. First, it says page, not component, so the stack trace is minified server-chunk noise and tells you nothing. Second, if it names "/404" or "/_not-found", the offending component is almost certainly in your root layout, which the not-found page inherits.
Why it only breaks the build and never dev
In the App Router, every route is a candidate for static prerendering. At build time Next.js renders your React tree to HTML on the server, with no incoming request. useSearchParams() needs the query string of a request that has not happened yet.
React's answer to "this value is not available yet" is a Suspense boundary. When a client component reads search params during prerender, Next.js suspends it and looks upward for the nearest boundary. If it finds one, it emits the fallback into the static HTML and hydrates the real thing on the client. That is the CSR bailout: the subtree bails out of server rendering and becomes client-rendered.
If there is no boundary above it, the bailout has nowhere to stop, so the entire page would have to be client-rendered. Next.js refuses to do that silently, because the result is a blank page until JavaScript loads, and you would never notice until a real user on a slow connection did. So it fails the build instead.
next dev never hits this because dev renders per request. There is always a request, so there is always a query string, so the hook never suspends. The check is not disabled in dev; it is structurally unreachable.
The fix, step by step
Step 1: Find the component, not the route
The error names a route. You need the component. Since Next.js 15.x the build has a flag for exactly this:
npx next build --debug-prerender
This disables minification for the prerender pass and uses source maps, so the stack trace names your file instead of chunks/8231.js. If you are on an older version, fall back to grep:
grep -rn "useSearchParams" app/ components/ src/ --include="*.tsx" --include="*.jsx"
Then check which of those components is reachable from the failing route. Shared navigation bars, analytics wrappers, "back to previous filter" buttons and cookie banners are the usual culprits, because they live in a layout and therefore touch every route.
Step 2: Split the component so the hook lives below a boundary
This is the part people get wrong. A Suspense boundary only helps if it is above the suspending component in the tree. Putting <Suspense> inside the same component that calls the hook does nothing, because the hook has already been called by the time you render the JSX.
Broken:
'use client'
import { useSearchParams } from 'next/navigation'
import { Suspense } from 'react'
export default function Filters() {
const params = useSearchParams() // suspends HERE
return (
<Suspense fallback={null}> {/* too late, this never runs */}
<div>Filtering by {params.get('q')}</div>
</Suspense>
)
}
Working. Two components in one file, the hook in the inner one, the boundary in the exported wrapper:
'use client'
import { useSearchParams } from 'next/navigation'
import { Suspense } from 'react'
function FiltersInner() {
const params = useSearchParams()
return <div>Filtering by {params.get('q') ?? 'everything'}</div>
}
export default function Filters() {
return (
<Suspense fallback={<div className="h-6 w-40 animate-pulse rounded bg-neutral-200" />}>
<FiltersInner />
</Suspense>
)
}
Every call site of <Filters /> is now safe without changing any of them. That is the whole trick: fix it once, at the component that owns the hook, instead of scattering boundaries around your pages.
Give the fallback a real skeleton with roughly the same height as the loaded content. A null fallback is legal and will pass the build, but it causes a visible layout jump on first paint, which is a Cumulative Layout Shift regression you have just traded the build error for.
Step 3: For genuinely dynamic pages, opt out of prerendering instead
Sometimes the page is not static in any meaningful sense. An admin dashboard behind auth, a search results page, an OAuth callback: prerendering it buys you nothing. In that case say so explicitly in the server component page or layout:
// app/search/page.tsx (server component, no 'use client')
import { connection } from 'next/server'
import Results from './results'
export default async function Page() {
await connection()
return <Results />
}
connection() waits for an actual incoming request, which excludes everything below it from prerendering. On older versions the equivalent is export const dynamic = 'force-dynamic' in a server page.tsx or layout.tsx.
Critical caveat: setting export const dynamic in a file that starts with 'use client' has no effect at all. This is why so many people report "I added force-dynamic and the error did not go away". Their page was a client component. The route segment config is a server-side directive.
Step 4: Prefer passing searchParams down from the server
If the page is already a server component, you often do not need the hook. Server component pages receive searchParams as a prop, which in current Next.js versions is a Promise:
// app/products/page.tsx
import { Suspense } from 'react'
import ClientSearch from './client-search'
export default function Page({
searchParams,
}: {
searchParams: Promise<{ q?: string }>
}) {
return (
<Suspense fallback={<p>Loading</p>}>
<ClientSearch searchParams={searchParams} />
</Suspense>
)
}
The client component unwraps it with React's use(). You still need the boundary, but now the data flows down the tree instead of being pulled sideways out of a router hook, which is easier to test and easier to reason about.
Verification: prove the route is still static
Do not just check that the build passed. Check that you did not accidentally turn your marketing pages into server-rendered ones. Run the build and read the route table:
npx next build
Route (app) Size First Load JS
┌ ○ / 1.2 kB 98 kB
├ ○ /dashboard 4.8 kB 112 kB
└ ƒ /search 2.1 kB 104 kB
○ (Static) prerendered as static content
ƒ (Dynamic) server-rendered on demand
A ○ next to /dashboard means the Suspense fix worked and the page still ships as static HTML with only the filter subtree hydrating on the client. A ƒ next to /search is expected if you used connection() there. A page you expected to be static showing ƒ means your opt-out landed in a layout and cascaded down to every child route. That is the failure mode worth catching.
Then confirm the static shell is not blank:
npx next start &
curl -s http://localhost:3000/dashboard | grep -c "<main"
If that returns 0, the page is still bailing out to CSR somewhere and you have only moved the problem. The whole point of the narrow boundary is that everything except the filter renders in the initial HTML.
What people get wrong
| Advice you will find | Why it is wrong |
|---|---|
experimental.missingSuspenseWithCSRBailout: false | Only ever existed in Next.js 14 and is being removed. It does not fix anything, it silences a warning about a page that will render blank until JS loads. |
Wrap the whole page body in one <Suspense> | Passes the build, but now the entire page is the bailout subtree and prerendering gives you a skeleton and nothing else. You kept the cost and lost the benefit. |
Add export const dynamic = 'force-dynamic' to the client component | Silently ignored. Route segment config only applies in server components. |
Put force-dynamic in the root layout | It works, and it also opts every route in your application out of static generation. Your landing page now costs a server render per visit. |
Replace the hook with window.location.search | Breaks server rendering entirely (window is not defined), or forces you into a useEffect, which means the value is undefined on first paint and the UI flashes. |
There is a related trap worth naming: reaching for suppressHydrationWarning when the split causes a mismatch. That flag hides the symptom and freezes the server value in place. I wrote about that specific failure in why suppressHydrationWarning freezes the server value, and about tracking down mismatches properly in finding the real hydration mismatch.
When it is still broken
- The error names "/404" or "/_not-found". Something in your root layout calls the hook. Next.js prerenders the not-found page using that layout, so the boundary has to go inside the layout, around the offending component.
- It is a third-party package. Some analytics and auth libraries call
useSearchParams()internally. You cannot edit them, so wrap their provider or widget in your own<Suspense>at the point where you render it. - You are using
output: 'export'. Static export cannot server-render anything, soconnection()andforce-dynamicare not available to you. The Suspense split is the only option. - The build passes locally but fails in Docker. Check that both are on the same Next.js minor version and that your Dockerfile is not restoring a stale
.nextcache. If the container has less memory than your laptop, you may actually be hitting a different failure entirely, which I covered in next build JavaScript heap out of memory.
The durable lesson is not about this hook. It is that next dev is a development convenience, not a preview of production. Any App Router bug involving prerendering, caching, or the server/client boundary is invisible in dev by construction. Put next build in your pre-push hook or the first job of your CI pipeline, and you will find these on your own machine instead of in a deploy log at midnight.
The official write-up of this specific error lives at nextjs.org/docs/messages/missing-suspense-with-csr-bailout.
Frequently asked questions
- Why does useSearchParams() only fail during next build and not in next dev?
- The dev server renders every page on demand, so a real HTTP request always exists and useSearchParams() can read its query string immediately. next build tries to prerender pages to static HTML with no request, so the hook suspends and needs a Suspense boundary above it. The check is not disabled in dev, the condition simply cannot occur there.
- Where exactly should the Suspense boundary go?
- Above the component that calls the hook, never inside it. The reliable pattern is two components in one file: an inner one that calls useSearchParams(), and an exported wrapper that renders the inner one inside a Suspense boundary. Fixing it at the component means every page that uses it is fixed at once.
- I added export const dynamic = 'force-dynamic' and the error did not go away. Why?
- Route segment config only takes effect in server components. If the file containing it starts with 'use client', Next.js ignores it entirely. Move the directive into a server component page.tsx or layout.tsx, or better, call await connection() in a server component, which excludes everything below from prerendering.
- The error names page "/404" but I do not have a 404 page using search params. What is going on?
- A component in your root layout is calling useSearchParams(), and Next.js prerenders the built-in not-found page using that same root layout. Find the hook in the layout tree, usually a navigation bar, analytics wrapper or cookie banner, and wrap that component in a Suspense boundary inside the layout.