DEV Community

Anas Sheikh
Anas Sheikh

Posted on

Fix: useSearchParams() Should Be Wrapped in a Suspense Boundary (and Why It Only Breaks at Build)

You add a small search box to your navbar. It reads the query string, so you reach for useSearchParams(). Everything works in next dev. You push, the deploy fails, and the build log says:

useSearchParams() should be wrapped in a suspense boundary at page "/about".
Read more: https://nextjs.org/docs/messages/missing-suspense-with-csr-bailout
Enter fullscreen mode Exit fullscreen mode

Notice the page name. You did not touch /about. That detail is the whole story.

The Code That Triggers It

// components/SearchBox.tsx
'use client';

import { useSearchParams } from 'next/navigation';

export function SearchBox() {
  const searchParams = useSearchParams();
  const query = searchParams.get('q') ?? '';

  return <input defaultValue={query} placeholder="Search..." />;
}
Enter fullscreen mode Exit fullscreen mode
// app/layout.tsx
import { SearchBox } from '@/components/SearchBox';

export default function RootLayout({ children }: { children: React.ReactNode }) {
  return (
    <html lang="en">
      <body>
        <nav>
          <SearchBox />
        </nav>
        {children}
      </body>
    </html>
  );
}
Enter fullscreen mode Exit fullscreen mode

Nothing here looks wrong, and next dev agrees with you.

Why Only the Build Complains

At build time, Next.js tries to pre-render every page that does not obviously need request data into static HTML. That is the fast path, and it is the default.

But a static page is built once, before any visitor exists, so it has no query string. useSearchParams() needs one. Next.js handles this by bailing out of static rendering for that part of the tree: the component is skipped on the server and rendered in the browser instead.

The catch is how far the bail-out spreads. Without a Suspense boundary around the component, the nearest boundary is the page itself, which would turn the whole page into client-side rendering and quietly throw away the static HTML you were about to ship. Instead of letting that happen silently, the build fails and asks you to say where the boundary should be.

Dev mode renders every page on demand, so it never has to make that decision, which is why the error only shows up when you build.

Why It Hits Every Page at Once

I put the component in layout.tsx on purpose. A shared layout wraps every page, so one unguarded useSearchParams() in a navbar breaks the build for /, /about, /pricing and every other static route at the same time.

That is also why the error names a page you never edited. The problem lives in a shared component.

Fix 1: Wrap the Component in Suspense

// app/layout.tsx
import { Suspense } from 'react';
import { SearchBox } from '@/components/SearchBox';

export default function RootLayout({ children }: { children: React.ReactNode }) {
  return (
    <html lang="en">
      <body>
        <nav>
          <Suspense fallback={<input disabled placeholder="Search..." />}>
            <SearchBox />
          </Suspense>
        </nav>
        {children}
      </body>
    </html>
  );
}
Enter fullscreen mode Exit fullscreen mode

Now only the search box is rendered in the browser. Everything else stays static HTML. The fallback is what visitors see until the client takes over, so make it the same size as the real component to avoid the layout jumping when it swaps in.

This is the fix to reach for first. It keeps your pages static and limits the client-side part to the one component that needs it.

Fix 2: Read the Params on the Server and Pass Them Down

If the value is only needed on one page, skip the hook entirely. Pages receive searchParams as a prop:

// app/search/page.tsx
export default async function SearchPage({
  searchParams,
}: {
  searchParams: Promise<{ q?: string }>;
}) {
  const { q } = await searchParams;
  return <SearchResults query={q ?? ''} />;
}
Enter fullscreen mode Exit fullscreen mode

In Next.js 15, searchParams is a Promise, so it has to be awaited. Reading it marks that page as dynamic, so it renders per request instead of at build time. For a real search results page that is exactly what you want.

Fix 3: Force the Route to Be Dynamic

export const dynamic = 'force-dynamic';
Enter fullscreen mode Exit fullscreen mode

This also makes the error disappear, but it does so by giving up static rendering for the route. Use it when the page genuinely depends on the request, not as a way to silence the build. On a marketing page it trades speed for nothing.

Which Fix When

Situation Fix
Small interactive component reads the URL (navbar search, filters, tabs) Suspense around that component
An entire page depends on the query string Read searchParams in the page
The page really is per-request force-dynamic

A Detail Worth Knowing About the Fallback

Whatever sits inside the Suspense boundary is client-rendered, so it will not be in the initial HTML. For a search input that does not matter. For content you want indexed, keep it outside the boundary. A fallback that does not match the final size also adds layout shift, which counts against your Core Web Vitals. I cover what those metrics measure in this plain-language guide.

The Rule

A client component that reads request-time data needs a Suspense boundary, so Next.js knows how much of the page to make dynamic. Put the boundary as close to the component as you can, and the rest of the page stays static.

I wrap every URL-driven component this way across my Next.js templates, so the production build never depends on where a navbar or filter bar ends up in the layout.

Get the templates: https://pixelanas.gumroad.com

Search your project for useSearchParams and check that every use sits inside a Suspense boundary, especially anything imported by layout.tsx. If you have hit a different build-only error, drop it in the comments.


Anas, full-stack Next.js developer building SaaS products and premium templates. X: @ASheikh69751

Top comments (0)