DEV Community

Cover image for My Next.js 16 button was visible and completely dead in production. Here's why.
AurinAilean
AurinAilean

Posted on

My Next.js 16 button was visible and completely dead in production. Here's why.

I added a tiny test page to confirm my error monitoring was capturing frontend crashes. A title, a paragraph, one red button that throws an error on click. The kind of code you'd send to a code review and apologize for being trivial.

Locally: worked perfectly. Click, error, captured, done.

In production: the button rendered. Click did nothing. No error in the console. No network request. No visible feedback. Just dead HTML that looked exactly like a button.

I'm a solo dev building an iOS market intelligence tool, and this bug burned an hour of my pre-launch sprint. The cause is a real Next.js 16 trap. The fix is well-documented. The path between "this should work" and "ah, it's this" is what I want to write about — because the same shape of bug is going to bite a lot of people once Next 16 spreads.

What I had

"use client"
import { useSearchParams } from "next/navigation"

export default function SentryTestPage() {
  const key = useSearchParams().get("key")

  if (!key) return <LockedScreen />

  return (
    <button onClick={() => { throw new Error("test crash") }}>
      Trigger crash
    </button>
  )
}
Enter fullscreen mode Exit fullscreen mode

A page that reads a ?key= query param. If the key is missing, it shows a locked screen. If present, it shows the button. Trivial.

In production with ?key=... in the URL, the button rendered. But nothing happened on click. No event, no error, no log. The button was a div pretending to be a button.

What I tried first (and why each was wrong)

Theory 1: The env variable isn't set.

I thought maybe NEXT_PUBLIC_SENTRY_DSN wasn't actually present in the build. So Sentry never initialized, so when I threw an error there was no one to catch it. I checked Vercel. The variable was there. I re-deployed without build cache to make sure. Still dead.

This was the right thing to check — NEXT_PUBLIC_* vars are inlined at build time, and adding them later doesn't help unless you rebuild. But it wasn't the bug.

Theory 2: A floating widget is overlaying the button.

The app has a feedback button fixed to the bottom-right corner. Z-index issues are a classic source of invisible click eaters. I inspected. The button received pointer-events: auto. Nothing was on top. Not it.

Theory 3: The build is stale.

Hard-reload with Cmd + Shift + R. Then incognito. The button still didn't respond. Not the cache.

Theory 4: React just isn't hydrating the page.

This was closer. If React fails to hydrate a subtree, the HTML is there but the JavaScript event handlers never attach. That'd look exactly like what I was seeing.

What the actual bug is

Next.js 16 (and 13+, but it's stricter in 16) requires that any component reading useSearchParams() be wrapped in a <Suspense> boundary. Without it, here's what happens:

  1. During server-side rendering, useSearchParams() returns an empty params object. The component renders the "no key" branch (<LockedScreen />).
  2. On the client, the URL has ?key=..., so useSearchParams() returns the params, and the component wants to render the "button" branch.
  3. React tries to hydrate the server HTML and finds a mismatch: the server rendered <LockedScreen />, the client wants to render the button.
  4. React aborts hydration for that subtree. It logs a warning during development (if you're watching the console) but in production it just stops.
  5. The HTML the server sent — including whichever branch happened to be there — stays in the DOM. But no event handlers ever attach.

That last point is the cruel part. In my case the server had rendered the locked screen, but my browser then swapped to the button in the DOM via... actually I'm not entirely sure how the button appeared at all, given the hydration was supposed to be aborted. I suspect a streaming SSR quirk where the client did render the button but React refused to wire it up. Either way: the button I saw was dead HTML.

It worked locally because dev mode uses a different hydration strategy that is more forgiving of mismatches — it logs the warning and patches the DOM. Production mode does not.

The fix

The documented Next.js 16 pattern is to put the useSearchParams() call inside a child component, and wrap that child in <Suspense>:

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

function PageContent() {
  const key = useSearchParams().get("key")
  if (!key) return <LockedScreen />
  return (
    <button onClick={() => { throw new Error("test crash") }}>
      Trigger crash
    </button>
  )
}

export default function SentryTestPage() {
  return (
    <Suspense fallback={null}>
      <PageContent />
    </Suspense>
  )
}
Enter fullscreen mode Exit fullscreen mode

Why this works: <Suspense> tells React that the wrapped content might not be ready during initial server render. The server emits the fallback (null), and the client renders the real content. Because the server doesn't commit to a branch, there's no mismatch to resolve. The client takes over cleanly, the button hydrates, the onClick attaches.

After this fix the button worked on the first deploy.

What I should have done first

The build log was actually telling me. Next.js prints a warning during production builds when a page uses useSearchParams() without <Suspense>:

Entire page deopted into client-side rendering. Read more: ...
Enter fullscreen mode Exit fullscreen mode

I didn't see it because I was looking at runtime logs, not build logs. If I'd grep'd the build output for deopted, I would have found this bug in 30 seconds instead of an hour.

So the rule I'm internalizing:

When something works locally and breaks in production, read the build log first. The framework probably already told you what's wrong. You're just not looking where it told you.

A close second:

When a React event handler doesn't fire, suspect hydration before suspecting your code. The button isn't broken — the path from rendered HTML to JavaScript-wired DOM is broken. Different debug target, different fix.

The pattern that made this worse than it needed to be

I had an AI pair (Claude Code) writing most of the actual edits during this sprint. The AI is very good at producing plausible code fast. When I told it to add a test page with useSearchParams() and a button, it wrote code that worked in dev mode — which is what AI training data is full of, because most demo apps run in dev.

The Suspense boundary requirement is the kind of detail that lives in framework upgrade guides, not in tutorials. Training data doesn't catch it. I have to.

The lesson isn't "AI bad." The lesson is that AI is great at execution and dangerous at diagnosis. It can write the next line of code as fast as I can read it. But when something is wrong, it tends to confidently propose plausible explanations. If I act on the plausible ones without verifying, I waste an hour. If I reproduce first — actually click the button, actually read the build log — the wrong explanations get killed in three minutes.

That's been the meta-pattern across every bug in this sprint. Plausible was wrong. Reproduction was right. Three minutes of curl/click/grep beat three hours of "let me try this fix."

Why I'm writing this

I'm Aurin. I'm building AppStoreAnalyzer — a tool that scores 50+ iOS app niches across 20 markets, so indie devs can see which niches have room before they spend three months coding. It's pre-launch. I'm writing a build-in-public log as I get to launch.

This is post one. If you build iOS apps and want to look at niche data, the explorer is free and there's no signup wall. If you ever hit a Next.js 16 hydration bug, hopefully this post saved you an hour.

More posts as the launch gets closer. If this bug made you wince in recognition, we'd get along.

Top comments (0)