Next.js error
useSearchParams() Should Be Wrapped in a Suspense Boundary
The error
useSearchParams() should be wrapped in a suspense boundary at page "/". Read more: https://nextjs.org/docs/messages/missing-suspense-with-csr-bailoutThis is not a warning about loading states. It is Next.js telling you that one hook has just made an entire page client-rendered, and asking you to contain the damage.
The message reads like a styling suggestion. It is not. It is Next.js telling you that a build-time render just became impossible for the whole page, and offering you a way to limit it to the part that caused it.
What actually happened
useSearchParams reads the query string. The query string is not known when
the page is prerendered - /products?sort=price and /products?sort=name are
the same build output. So a component calling this hook cannot be rendered at
build time.
Next.js does not have a way to render part of a page late unless you tell it where the boundary is. Without one, it takes the only safe option: it bails out of prerendering the entire route and renders it on the client. Your page now ships as an empty shell that fills in after JavaScript loads.
The error is asking where the boundary should go.
The fix
Put the component that calls the hook inside <Suspense>, with a fallback
that occupies the same space:
// app/products/page.tsx - stays static
import { Suspense } from 'react';
import { SortControls } from './sort-controls';
export default function Page() {
return (
<>
<h1>Products</h1>
<Suspense fallback={<div className="h-10" />}>
<SortControls />
</Suspense>
<ProductGrid />
</>
);
}// app/products/sort-controls.tsx
'use client';
import { useSearchParams } from 'next/navigation';
export function SortControls() {
const params = useSearchParams();
// ...
}The heading and the grid are prerendered and served from the edge. Only
SortControls waits.
Two details decide whether this is worth doing:
The boundary has to be a real boundary. Wrapping the whole page body in
<Suspense> satisfies the error and changes nothing - everything inside it is
still client-rendered. Push the boundary as far down as it will go.
The fallback needs the final height. A fallback that is shorter than what replaces it produces a layout shift at the moment of hydration, which is one of the more reliable ways to lose a Cumulative Layout Shift score.
The better fix, where it applies
If the component reading the query string does not need to be a client
component, do not make it one. A page receives searchParams as a prop:
export default async function Page({
searchParams,
}: {
searchParams: Promise<{ sort?: string }>;
}) {
const { sort } = await searchParams;
return <ProductGrid sort={sort} />;
}This still makes the route dynamic - the query string is request data - but it renders on the server, so the HTML arrives complete. That is a materially different outcome from a client-side bailout, and it is the right choice whenever the value is only read, not reacted to.
What looks like a fix and is not
export const dynamic = 'force-dynamic'. This removes the error by
declaring the route dynamic, which sounds like agreement with the diagnosis. It
is not the same thing: you have given up static rendering for the whole route
permanently, including every part of it that had no reason to be dynamic. The
Suspense boundary exists so you do not have to.
Moving the hook up to the page. The page is then the client component, and
everything under it comes with it. This is the opposite of what the error is
asking for, and
where use client sits is most of what decides your bundle.
Reading window.location.search instead. The error goes away because the
hook is gone. So does the page's ability to react to client-side navigation,
and you have added a window reference that will produce
a hydration mismatch the
first time it renders during SSR.
Finding the rest of them
One of these on a route is usually not the only one. The build output marks
dynamic routes with ƒ and static routes with ○ - a route you expected to
be static and that is not is the same class of problem, and
an accidental dynamic render is the most common finding in any audit we
run.
