Skip to content

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-bailout

This 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.

Related questions

Can I just delete the hook?
Often, yes, and it is the better answer when the component is a Server Component's child that only needed one query value. Read the parameter on the server from the page's searchParams prop and pass it down. The hook exists for client components that must react to navigation without a re-render from the server.
Does wrapping it in Suspense make the page static again?
The page shell becomes static; the wrapped subtree stays client-rendered. That is the point - you are choosing which part of the page pays, instead of the whole route paying.