Next.js

useSearchParams Suspense Error: 3 Real Fixes (Next.js 15)

The error only surfaces during the production build — dev mode hides it completely. Here is why, and the three fixes ranked by situation.

Mahdi Ben RhoumaMahdi Ben Rhouma
Updated
6 min read

A search UI can work in next dev and then fail during the production build with:

plaintext
Error: Missing Suspense boundary with useSearchParams
Read more: https://nextjs.org/docs/messages/missing-suspense-with-csr-bailout

The confusing part: this error never appears in development. In dev, routes render on-demand, so useSearchParams never suspends. During next build, static pages are prerendered ahead of time — and that is when Next.js hits the wall.

Why this happens#

useSearchParams is a Client Component hook that reads URLSearchParams from the current URL. The problem is that URL query strings are request-time data — they cannot be known at prerender time. When Next.js tries to statically render a page that contains useSearchParams (directly or in a child component), it needs a Suspense boundary to split the page: the static shell can be prerendered, and the dynamic part with useSearchParams gets hydrated client-side.

Without that boundary, Next.js has nowhere to cut — it fails the build rather than silently serving wrong content.

The rule from the Next.js docs (v16.2.9): during production builds, a static page that calls useSearchParams from a Client Component must be wrapped in a Suspense boundary, otherwise the build fails.

Fix 1 — Wrap in Suspense (fastest)#

This is the minimal fix. Wrap the component using useSearchParams in a <Suspense> boundary with a fallback:

jsx
import { Suspense } from 'react'
import SearchBar from './search-bar'
 
function SearchBarFallback() {
  return <div className="h-10 w-full animate-pulse bg-gray-100 rounded" />
}
 
export default function Page() {
  return (
    <>
      <nav>
        <Suspense fallback={<SearchBarFallback />}>
          <SearchBar />
        </Suspense>
      </nav>
      <h1>Dashboard</h1>
    </>
  )
}
jsx
'use client'
 
import { useSearchParams } from 'next/navigation'
 
export default function SearchBar() {
  const searchParams = useSearchParams()
  const search = searchParams.get('search')
  return <input defaultValue={search ?? ''} placeholder="Search..." />
}

The fallback renders in the prerendered HTML. Once the page hydrates, React replaces it with the real SearchBar.

When to use this: when the component genuinely needs to live in a Client Component and you want to keep the logic self-contained.

If the parent Page component is a Server Component (which is the default), you can read search params directly from the searchParams prop and pass the value down as a plain prop. No hook, no Suspense required:

jsx
import SearchBar from './search-bar'
 
export default async function Page({ searchParams }) {
  const { search } = await searchParams
  return (
    <>
      <nav>
        <SearchBar initialSearch={search ?? ''} />
      </nav>
      <h1>Dashboard</h1>
    </>
  )
}
jsx
'use client'
 
export default function SearchBar({ initialSearch }) {
  return <input defaultValue={initialSearch} placeholder="Search..." />
}

One important caveat: Layouts do NOT receive the searchParams prop. Only Pages do. If you need search params inside a Layout, you must use useSearchParams with a Suspense boundary.

Reading the page's searchParams prop opts the page into dynamic rendering, because its value depends on the request. When to use this: when the Page already coordinates request-time data. To retain a static shell, use Fix 1 instead.

Fix 3 — Force dynamic rendering with connection()#

If the page genuinely should be dynamic (never prerendered), use connection() from next/server in the parent Server Component. This tells Next.js to wait for an actual incoming request before rendering, which makes useSearchParams safe without Suspense:

jsx
import { connection } from 'next/server'
import SearchBar from './search-bar'
 
export default async function Page() {
  await connection()
  return (
    <>
      <nav>
        <SearchBar />
      </nav>
      <h1>Dashboard</h1>
    </>
  )
}

The Next.js docs prefer connection() to express waiting for an incoming request. In Next.js 15, export const dynamic = 'force-dynamic' remains a documented route segment option; this preference is not a deprecation notice.

When to use this: when the entire page is inherently dynamic (authenticated dashboards, personalized feeds). You are trading prerender performance for simplicity.

Verify the fix by reading the route table#

After the build passes, check that the route's rendering mode matches the chosen remedy. Run a clean production build and check the marker next build prints next to your route:

bash
rm -rf .next
npx next build
text
Route (app)
┌ ○ /_not-found
├ ○ /
└ ƒ /dashboard
 
○  (Static)   prerendered as static content
ƒ  (Dynamic)  server-rendered on demand

○ means the route was prerendered into static HTML at build time — that's the expected outcome for Fix 1 when no other request-time API makes the route dynamic. ƒ means the route is rendered on demand per request. It is expected for both Fix 2 (reading the page's searchParams prop) and Fix 3 (connection()), as well as an explicit force-dynamic setting. A dynamic marker after Fix 1 calls for checking other request-time data, such as cookies() or headers(); the marker alone does not mean the Suspense boundary failed.

One placement mistake to watch for while you're in there: the <Suspense> boundary has to be an ancestor of the component calling useSearchParams, not a sibling or a child. If your tree is <Page> (itself calling the hook) → <Suspense> → <Child>, the hook runs before the boundary is reached and you still get the build error — wrapping the wrong node in <Suspense> doesn't rescue a hook call that happens above it.

One email a month — the latest explained errors

What the site published that month — mostly Next.js, Supabase and Node errors, explained. You confirm by email; unsubscribe any time in one click.

When the fix is the wrong fix#

Do not put connection() on a marketing page or a blog post to silence this error. Forcing dynamic rendering means the page is server-rendered on every request — you lose the static prerender performance that made you choose App Router in the first place.

If useSearchParams appears in a component buried inside a page that is otherwise fully static, the right answer is Fix 1 (Suspense) — not Fix 3. The Suspense boundary lets the static parts prerender while only the search bar is hydrated.

Reserve space for the search bar while it loads. A null Suspense fallback is valid, but can cause a layout shift when a visible control replaces an empty area. A skeleton or a container with the final dimensions avoids that specific shift.

Checklist after applying the fix#

  • Run next build locally and confirm it succeeds (do not rely on next dev).
  • Verify the Suspense fallback matches the height and width of the real component to avoid CLS.
  • If using Fix 2 (searchParams prop), confirm the component using the prop does NOT also call useSearchParams internally.
  • If using Fix 3 (connection), confirm the page is meant to be dynamic, not static.
  • Deploy and check the production page: the search bar should render correctly on first load without a flash.

Static export removes one of the three fixes entirely#

Fix 2 and Fix 3 require request-time server rendering. For output: 'export', use Fix 1. A project configured with output: 'export' uses a fully static export with no Next.js server at runtime, the mode used to deploy to plain static hosting — Fix 3 (connection(), or the older export const dynamic = 'force-dynamic') is not an option. Both mechanisms work by deferring rendering until an actual incoming request arrives at a server; a static export has no server process at request time to defer to, only pre-generated HTML files. Using either in a static export build fails for a different reason than the "missing Suspense boundary" error covered above: there is no request to wait for.

That leaves Fix 1 (wrap the component using useSearchParams in <Suspense>) as the only viable path for a page that needs both static export and a component that reads the URL's query string. The static shell prerenders at build time, the Suspense fallback ships in the static HTML, and the real value only becomes available client-side once the page hydrates in the browser and reads window.location — which is also why a page that depends on searchParams for its initial content (rather than a client-only enhancement) is a poor fit for static export in the first place: the server-rendered searchParams page prop from Fix 2 requires a Server Component render per request, which static export cannot do at all.

If the project mixes static export with Partial Prerendering concepts — prerendering a static shell while deferring specific dynamic parts — it's worth understanding the difference in mechanism before choosing between them: Next.js 15 Partial Prerendering: Guide covers how PPR uses Suspense boundaries to mark the dynamic "holes" in an otherwise static page on a server-rendered deployment, which is a related but distinct feature from output: 'export' — PPR still runs a Next.js server at request time to fill those holes, while static export runs no server at all. Confusing the two leads to exactly this symptom: a fix that works in one deployment mode silently doesn't apply in the other.

Further reading: Next.js Scroll Resets When searchParams Change, use client vs use server in Next.js: The Real Difference.

Frequently Asked Questions

|

Have more questions? Contact us

Still stuck? Paste your error.

Paste the error message or stack trace — we check it against every fix and tool on the site.

Matching happens in your browser. When you search, a cleaned error signature is sent so we can spot errors we do not cover yet — never the raw text, your IP address or your e-mail. Do not paste passwords or keys. Details

Written by

Mahdi Ben Rhouma
Mahdi Ben Rhouma

Full-Stack Dev — Next.js & Supabase

Solo developer building SaaS products with Next.js and Supabase. Writing about production patterns the official docs skip.

Remote