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

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

我的 Next.js 16 按钮在生产环境中可见但完全失效,原因如下。

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. 在生产环境中:按钮渲染出来了,但点击毫无反应。控制台没有错误,没有网络请求,没有任何可见反馈。它只是看起来像按钮的死 HTML。我是一名独立开发者,正在构建一个 iOS 市场情报工具,这个 Bug 浪费了我发布前冲刺阶段的一个小时。其原因是 Next.js 16 的一个真实陷阱。修复方法其实有详细文档记录。我想写写从“这应该能行”到“啊,原来是这样”的过程——因为一旦 Next 16 普及,很多人都会遇到同样类型的 Bug。

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>
  )
}

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. 这是一个读取 ?key= 查询参数的页面。如果缺少 key,则显示锁定屏幕;如果存在,则显示按钮。很简单。在生产环境中,当 URL 中带有 ?key=... 时,按钮渲染出来了,但点击后什么也没发生。没有事件,没有错误,没有日志。这个按钮就像是一个伪装成按钮的 div

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. 理论 1:环境变量未设置。 我以为 NEXT_PUBLIC_SENTRY_DSN 可能没有包含在构建中,导致 Sentry 未初始化,因此抛出错误时无人捕获。我检查了 Vercel,变量是存在的。为了保险起见,我清除了构建缓存并重新部署,但依然无效。检查这一点是正确的——NEXT_PUBLIC_* 变量是在构建时内联的,除非重新构建,否则事后添加无效。但这不是问题的根源。

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. 理论 2:浮动组件遮挡了按钮。 应用右下角有一个固定的反馈按钮。Z-index 问题是导致点击失效的常见原因。我检查了元素,按钮的 pointer-eventsauto,上方没有任何东西。也不是这个原因。

Theory 3: The build is stale. Hard-reload with Cmd + Shift + R. Then incognito. The button still didn’t respond. Not the cache. 理论 3:构建版本过旧。 我尝试了 Cmd + Shift + R 强制刷新,然后又用了无痕模式,按钮依然没反应。不是缓存的问题。

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. 理论 4:React 没有对页面进行水合(Hydration)。 这更接近真相。如果 React 未能水合子树,HTML 虽然存在,但 JavaScript 事件处理程序永远不会挂载。这看起来正是我所遇到的情况。

What the actual bug is

真正的 Bug 是什么

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: Next.js 16(以及 13+ 版本,但在 16 中更严格)要求任何读取 useSearchParams() 的组件都必须包裹在 <Suspense> 边界内。如果没有这样做,会发生以下情况:

During server-side rendering, useSearchParams() returns an empty params object. The component renders the “no key” branch (<LockedScreen />). On the client, the URL has ?key=..., so useSearchParams() returns the params, and the component wants to render the “button” branch. React tries to hydrate the server HTML and finds a mismatch: the server rendered <LockedScreen />, the client wants to render the button. React aborts hydration for that subtree. 在服务端渲染期间,useSearchParams() 返回一个空的 params 对象。组件渲染了“无 key”分支(<LockedScreen />)。在客户端,URL 包含 ?key=...,因此 useSearchParams() 返回了参数,组件想要渲染“按钮”分支。React 尝试水合服务端 HTML 时发现了不匹配:服务端渲染的是 <LockedScreen />,而客户端想要渲染按钮。React 随后中止了该子树的水合过程。

It logs a warning during development (if you’re watching the console) but in production it just stops. The HTML the server sent — including whichever branch happened to be there — stays in the DOM. But no event handlers ever attach. 在开发环境下,它会记录一条警告(如果你盯着控制台看的话),但在生产环境中它只是直接停止。服务端发送的 HTML(包括当时渲染的那个分支)会保留在 DOM 中,但没有任何事件处理程序被挂载。

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. 最后一点最残酷。在我的例子中,服务端渲染了锁定屏幕,但我的浏览器随后在 DOM 中切换到了按钮……实际上,考虑到水合过程本应中止,我也不确定按钮是怎么出现的。我怀疑这是流式 SSR 的一个小怪癖,客户端确实渲染了按钮,但 React 拒绝为其绑定事件。无论如何,我看到的按钮只是死 HTML。它在本地能用是因为开发模式使用了不同的水合策略,对不匹配的情况更宽容——它会记录警告并修补 DOM。而生产模式则不会。

The fix

修复方法

The documented Next.js 16 pattern is to put the useSearchParams() call inside a child component, and wrap that child in <Suspense>: Next.js 16 官方推荐的模式是将 useSearchParams() 调用放在子组件中,并将该子组件包裹在 <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>
  )
}

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. 为什么这样有效:<Suspense> 告诉 React,被包裹的内容在初始服务端渲染时可能尚未就绪。服务端会输出 fallback(null),而客户端会渲染真实内容。因为服务端没有预先确定渲染哪个分支,所以不存在需要解决的不匹配问题。客户端可以顺利接管,按钮完成水合,onClick 事件也成功挂载。修复后,按钮在第一次部署时就正常工作了。

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>: 构建日志其实已经告诉了我答案。当页面在没有 <Suspense> 的情况下使用 useSearchParams() 时,Next.js 会在生产构建期间打印警告:

Entire page deopted into client-side rendering. Read more: … 整个页面已降级为客户端渲染。阅读更多:…

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. 我没看到是因为我只看了运行时日志,没看构建日志。如果我用 grep 在构建输出中搜索 deopted,我可以在 30 秒内发现这个 Bug,而不是浪费一个小时。

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. 紧随其后的第二条规则:当 React 事件处理程序不触发时,先怀疑水合问题,再怀疑你的代码。按钮本身没坏,坏的是从渲染出的 HTML 到 JavaScript 绑定 DOM 的路径。调试目标不同,修复方法也不同。