On this page

← Blog
Tutorial6 min read

useSearchParams( ) should be wrapped in a suspense boundary. Here's what it actually breaks.

The Next.js build error is the easy half. The dangerous half is when the build passes, the page looks fine in a browser, and Google gets an empty div. Here is how to tell which one you have.

nextjsreactapp-routerssrseodebugging

useSearchParams( ) should be wrapped in a suspense boundary. Here's what it actually breaks.

If your Next.js build just failed with useSearchParams() should be wrapped in a suspense boundary at page "/your-page", the fix is to wrap the component that calls it in <Suspense>. That is the easy half, and it is the half every result explains.

The dangerous half is the case where the build passes. The page renders perfectly in your browser, and Google, Slack unfurls, and every AI crawler get an empty div. We shipped that exact bug last week on a page we care about, and neither the build nor the browser told us.

Why does Next.js require a Suspense boundary at all?

useSearchParams reads the query string. The server does not know the query string when it prerenders a static page, because one build output has to serve /start, /start?client=cursor, and every other variant.

So the App Router does the only correct thing: any component reading search params opts out of static rendering. Next.js calls this a client-side bailout. The server renders the nearest <Suspense> fallback, ships it, and the real content appears after hydration in the browser.

The error exists to make you choose where that boundary sits. Without one, the bailout climbs to the whole route, and the entire page becomes fallback HTML on the server.

What is the actual fix?

Move the useSearchParams call into its own child, and wrap that child:

jsx
'use client'
import { Suspense } from 'react'
import { useSearchParams } from 'next/navigation'

function ClientPreference() {
  const params = useSearchParams()
  return <p>Preselected: {params.get('client') ?? 'none'}</p>
}

export default function Page() {
  return (
    <main>
      <h1>Set it up once.</h1>          {/* stays server-rendered */}
      <Suspense fallback={null}>
        <ClientPreference />            {/* only this bails out */}
      </Suspense>
    </main>
  )
}

The boundary is a blast radius. Put it around the smallest component that needs the query string, never around the page.

Why did my build pass and the page still come back empty?

Because a boundary that wraps too much satisfies the compiler and loses the content anyway.

If your <Suspense> sits at the top of the page, the build has nothing to complain about. The rule was followed. But the server now renders your fallback for the entire route, so the HTML that reaches a crawler is whatever you passed to fallback, which for most people is null or a spinner.

Nothing warns you:

  • next build succeeds, because the boundary exists
  • the browser looks correct, because hydration fills it in within milliseconds
  • Lighthouse often looks fine, because it runs JavaScript

Google renders JavaScript too, eventually, on a second pass it schedules at its own pace. Slack, LinkedIn, X, and most AI crawlers do not. They read the first HTML response and leave.

How do I check what a crawler actually receives?

Not with next dev, and not by opening the page. Curl the production build and grep for text you know is on the page:

bash
npx next build && npx next start -p 3111
curl -s localhost:3111/your-page | grep -c "some real sentence from the page"

0 means the server sent nothing readable. Check the size too:

bash
curl -s localhost:3111/your-page | wc -c

Ours came back 21,824 bytes and every one of them was layout shell: nav, footer, an empty div where the page should be. That is the signature. Plenty of bytes, none of them content.

For a faster check without a build, disable JavaScript in your browser and reload. Whatever remains is roughly what a crawler sees.

Can I avoid the boundary entirely?

Often, yes, and on a public page you usually should.

If you only need the query string for a preference, read it after mount instead. No bailout, no boundary, and the server keeps rendering the real page:

jsx
'use client'
import { useEffect, useState } from 'react'

export default function Page() {
  const [client, setClient] = useState(null)

  useEffect(() => {
    const params = new URLSearchParams(window.location.search)
    setClient(params.get('client'))
  }, [])

  return (
    <main>
      <h1>Set it up once.</h1>          {/* server-rendered, always */}
      {client && <p>Preselected: {client}</p>}
    </main>
  )
}

The tradeoff is honest: the preference applies one frame late, and it is not available during server render. For a marketing page, a docs page, or anything in your sitemap, that is a good trade. For an app screen behind a login, where nothing is being crawled, useSearchParams with a tight boundary is simpler and fine.

Pick by whether the page needs to be readable without JavaScript, not by which API is newer.

What we actually shipped

Our /start page routes by editor. Cursor users get Cursor instructions, Claude Code users get the MCP path. The first version read ?client= with useSearchParams.

The build was clean. The page worked. We only found it because we curled the running server before shipping, on a page that is public, canonical, and in our sitemap. Every crawler would have received an empty div on the page whose entire job is telling people how to install the product.

The rule we wrote down afterward, which is the only reason this post exists:

A clean next build says nothing about what a crawler receives. Curl the running page and grep for real content.

That class of bug is the reason we build DebugAI: the failures that cost the most are the ones that never throw. No stack trace, no red text, nothing to paste into a chat window. Just a page that quietly stopped existing for everyone who is not you.

Debug faster starting today.

Free VS Code extension · 10 sessions/day · no credit card

Install free →

Related posts

Tutorial

ImportError: cannot import name 'X' from 'Y'. Three causes, and how to tell them apart.

5 min read

Tutorial

How to Debug a Next.js Application in VS Code (Complete Guide)

8 min read

← All posts