I hit the build error useSearchParams() should be wrapped in a suspense boundary at page "/" for the first time while shipping a Supabase-backed filter UI in Next.js 15, and the fix was a one-line wrap: put the component that calls useSearchParams inside a <Suspense> boundary, and the rest of the page prerenders normally. This guide covers the exact pattern, the real reason the build refuses to ship without a boundary, and a verification step that proves the route stayed in the static output. I also link to the longer-form version of this fix in Missing Suspense Boundary with useSearchParams (Next.js) and to the broader query-reading primer in How to get query string parameters in Next.js.
Shortcut: the free Next.js Build Error Decoder recognizes this exact prerender error — paste your build output and jump straight to the matching fix.
The exact error and when it fires
The error you see in your terminal during next build looks like this:
$ npx next build
▲ Next.js 15.0.0
Error: useSearchParams() should be wrapped in a suspense boundary at page "/products".
Read more: https://nextjs.org/docs/messages/missing-suspense-with-csr-bailout
It fires when three things are true at the same time. First, a Client Component — a file with the "use client" directive at the top — calls the useSearchParams hook from next/navigation. Second, that Client Component is being prerendered, which is the default in the App Router for any route that does not opt out. Third, there is no <Suspense> boundary above the call in the rendered tree. When all three conditions are met, Next.js refuses to fall back to dynamic rendering silently and instead throws, because silently bailing out would hide a real problem: the prerendered HTML would either be wrong or empty.
The same error appears in the browser console in dev mode when you navigate to the page directly, and it appears in your CI logs the moment you run next build against a route that violates the invariant. The official documentation page that the error links to is https://nextjs.org/docs/messages/missing-suspense-with-csr-bailout, and that page is the only place I trust for the canonical wording — the message has been stable across the 15.x line.
Why Next.js refuses to statically render useSearchParams
The hook reads the current URL's query string. At build time there is no browser and no URL — there is only a render function being asked to produce HTML for a route that may eventually be visited with any query string at all. Next.js handles this with a two-phase strategy. During prerender it renders a static shell, and during client hydration the URL is read and the actual UI is filled in. The mechanism that lets that two-phase render happen is <Suspense>: the boundary tells the prerender pass "you can stop here, emit a fallback, and let the rest resolve on the client."
Without a <Suspense> boundary above the call, the prerender pass has no instruction for how to stop. It would have to either invent a value for the search params (which would be wrong) or refuse to prerender the page at all. Next.js chooses the second option, which is why the build fails loudly rather than shipping a broken page. The same mechanism is what lets a Server Component fetch data and a Client Component read the URL in the same tree, as long as the Client Component is wrapped.
The reason this matters beyond just copying the fix is that the same invariant shows up in other forms. If you ever hit Dynamic Server Usage: couldn't be rendered statically because it used cookies or headers, that is the same boundary idea, and the fix is structurally identical — narrow the dynamic read to the smallest possible client subtree. I unpack that case in Fix Dynamic Server Usage Error in Next.js App Router.
The fix: split the page and wrap the reads in a Suspense boundary
The minimal change is to take any file that has "use client" at the top and calls useSearchParams, and refactor it so the page itself is a Server Component shell that hands the search-reading work to a child Client Component inside <Suspense>. The Server Component renders the static output, the Suspense boundary is the marker that tells the prerender pass where to stop, and the child Client Component hydrates on the client with the real URL.
Here is the broken version that throws during build. Do not ship it:
// app/(shop)/products/page.tsx
// ❌ Throws "useSearchParams() should be wrapped in a suspense boundary".
"use client";
import { useSearchParams } from "next/navigation";
export default function ProductsPage() {
const sp = useSearchParams();
const category = sp.get("category") ?? "all";
return (
<main>
<h1>Products</h1>
<p>Filtering by: {category}</p>
</main>
);
}
And here is the fix. The page becomes a Server Component, the search-reading logic moves to a child file marked "use client", and that child is rendered inside a <Suspense> boundary with a sensible fallback:
// app/(shop)/products/page.tsx
// ✅ Server Component shell. Renders to the static output at build time.
import { Suspense } from "react";
import ProductsFilter from "./products-filter";
export const metadata = {
title: "Products",
};
export default function ProductsPage() {
return (
<main>
<h1>Products</h1>
<Suspense fallback={<p>Loading filters…</p>}>
<ProductsFilter />
</Suspense>
</main>
);
}
// app/(shop)/products/products-filter.tsx
// ✅ Client Component. Called inside a <Suspense> boundary, so the
// prerender pass can emit the fallback and hydrate on the client.
"use client";
import { useSearchParams } from "next/navigation";
export default function ProductsFilter() {
const sp = useSearchParams();
const category = sp.get("category") ?? "all";
return <p>Filtering by: {category}</p>;
}
Two things stand out about this diff. First, the page.tsx no longer has "use client", so it runs on the server and gets the full Server Component benefits — metadata exports work, server-only imports work, and the route is eligible for static prerendering. Second, the fallback you pass to <Suspense> is what actually ships in the static HTML, so it should be a real loading state, not an empty fragment. A short paragraph or skeleton is usually enough.
Step-by-step: ship it without losing the static shell
- Find every file in your
app/tree that callsuseSearchParamsfromnext/navigation. Grep for it withrg "useSearchParams" app componentsto get a list in one shot. - For each one, check whether the file has
"use client"at the top. If the page itself is a Client Component, decide whether it really needs to be — most pages do not. - Refactor the file so the page exports a Server Component, and the search-reading logic moves to a sibling Client Component. The Server Component imports the child and renders it inside
<Suspense fallback={...}>. - If the child component was previously exported as the default of a page file, rename it to something like
*-view.tsxor*-filter.tsxand update the import inpage.tsx. - Re-run
next buildand confirm the offending route shows the○marker in the route table, which means it was prerendered. If the marker isλ, the Suspense boundary is not doing its job and the route is still dynamic.
Verify the route stayed static
The fastest way to confirm the fix is to read the route table that next build prints. Run a clean production build and look for the marker next to your route:
# 1. Clean any stale build output so the route table is fresh.
rm -rf .next
# 2. Run the production build and capture the full route table.
npx next build
# 3. Look for the route marker. ○ = static, λ = dynamic, ƒ = function.
The output you want looks like this. The ○ (hollow circle) marker is the indicator that the route was prerendered at build time:
Route (app) Size First Load JS
┌ ƒ /api/products 0 B 0 B
├ ○ / 123 B 82 kB
├ ○ /products 1.4 kB 89 kB
└ ○ /_not-found 871 B 82 kB
If you see λ (lambda) instead of ○ next to /products, the route is being treated as dynamic, which usually means a Server Component somewhere in the tree is reading searchParams, cookies(), or headers(). The <Suspense> boundary does not rescue that case — it only helps when the reads are confined to a Client Component subtree. If your build still shows λ after the refactor, walk the tree again and check for stray cookies() or headers() calls.
A second useful check is the prerendered HTML itself. Inspect the page source after deployment and confirm the Suspense fallback is in the static output, not the search-reading markup. The fallback is what search engines and link-preview bots see, so it should be a sensible message rather than an empty fragment.
Two patterns that still trip you up
The whole page is "use client". This is the most common cause I see in code reviews. A developer adds "use client" to a page file because they need one tiny bit of interactivity, and the whole page becomes a Client Component. The fix still works inside the same file — you can wrap the search-reading JSX in <Suspense> directly — but you lose Server Component benefits for that route, including the metadata export, server-only imports, and the ability to call Supabase from the page itself. If the page is on a Supabase stack and the page itself should be able to fetch from the database, split it. The page becomes a Server Component, the search-reading JSX moves to a child file marked "use client", and the rest works as in the diff above.
The <Suspense> boundary is below the useSearchParams call. Boundaries do not work retroactively. If your tree is <ProductsPage> (Client Component, calls useSearchParams) → <Suspense> → <Child>, the prerender pass has already hit the hook before it sees the boundary, and you still get the build error. The boundary has to be above the call, which usually means the hook and the boundary live in the same Client Component subtree, or the page itself is the boundary's parent.
A related case is reading searchParams in a Server Component, which does not require <Suspense> at all. The searchParams prop on a Server Component page is fully resolved at request time, and the page is then marked dynamic. That is a different problem from this one — I cover it separately in How to get query string parameters in Next.js — but the two errors often get conflated, so it pays to be clear about which one you are actually hitting.
Why this happens and how to keep it from coming back
The underlying invariant is simple: any Client Component that reads runtime browser state (URL, window, localStorage) must live inside a boundary that the prerender pass knows how to stop at. <Suspense> is that boundary, and the hook contract on useSearchParams is that the caller guarantees one is present. There is no point in trying to read useSearchParams outside a boundary — the hook has no way to return a value during prerender, so the only correct options are the boundary or making the route dynamic.
The cheapest way to keep this from regressing is to add a build step to CI that runs next build and fails the pipeline on this specific error string. I have a short script for this in Next.js + Supabase CI/CD Pipelines with GitHub Actions, and the same pattern works whether the destination is Vercel, Fly, or a plain Node server. The other half of prevention is the file-level rule of thumb: if a file in app/ calls useSearchParams, the file that imports it must be a Server Component that wraps the import in <Suspense>. Code review catches this fast, and rg "useSearchParams" app should be a routine command in your day-to-day work.
If your stack ships to production behind a build step — and on a Next.js + Supabase project it should, because the static output is what makes the edge fast — make this rule a checklist item in the deployment guide. I keep the production checklist in Deploying Next.js + Supabase to Production, and the Suspense invariant is one of the items I check before a green build.
FAQ
What does "useSearchParams() should be wrapped in a suspense boundary" mean in plain English? It means a Client Component in your tree is reading the URL during a render that Next.js is trying to prerender, and there is no <Suspense> boundary above the call to tell the prerender pass where to stop. Add the boundary, with a fallback, and the route can ship as a static page.
Can I just make the route dynamic and skip the Suspense boundary? Yes. Adding export const dynamic = "force-dynamic" to the page, or reading searchParams in a Server Component, both turn off static prerendering for that route. The trade-off is real: you lose the static cache and the page renders on every request. Wrap the reads in <Suspense> if you can; fall back to dynamic only if the page genuinely cannot be split.
Does the Suspense fallback hurt SEO or accessibility? No. The fallback is what ships in the static HTML, and search engines index the static HTML. The search-reading subtree hydrates on the client and replaces the fallback with the real content. Make the fallback descriptive — "Loading filters…" is fine — and you do not lose anything.
I added Suspense and the build still throws. What now? Walk the tree above the failing call. The boundary must be a parent of the component that calls the hook, not a sibling or a child. If the page itself is "use client", the boundary inside the same file works but you lose Server Component benefits — split the file. If the page is a Server Component, the boundary must wrap the child that calls the hook, not the page.
Is there a way to read the query string without a Client Component at all? Yes. In the App Router, a Server Component page receives searchParams as a prop. That prop is fully resolved at request time, so the page becomes dynamic, but you do not need useSearchParams and you do not need <Suspense>. Use the prop when the whole page is request-dependent; use useSearchParams inside <Suspense> when only a small subtree is.
Related
- Missing Suspense Boundary with useSearchParams (Next.js)
- How to get query string parameters in Next.js
- Fix Dynamic Server Usage Error in Next.js App Router
- Deploying Next.js + Supabase to Production
- Next.js + Supabase CI/CD Pipelines with GitHub Actions
Originally published at https://www.iloveblogs.blog
Top comments (0)