ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

Next.js 自定义错误页实战:404/500 与错误边界全解析

Next.js 自定义错误页实战:404/500 与错误边界全解析

Next.js 项目里的默认错误页面,说实话,就是那种最标准的“404 Not Found”白底黑字,或者说“Application error: a client-side exception has occurred”的提示。如果你只见过本地开发环境,没上过线,可能永远注意不到这事。但一旦用户访问到某个失效链接,或者服务端抛了一个未捕获异常,那个粗糙的默认页面会瞬间拉低产品质感,甚至让用户怀疑网站是不是挂了。

这个项目就是围绕 Next.js 中自定义错误页面展开的,核心覆盖 App Router 和 Pages Router 两套体系下的 404 页面、500 页面、错误边界组件(error.js、global-error.js、not-found.js)的实现方式。适合正在维护 Next.js 项目的前端开发者,尤其是产品已经上线、开始关心用户体验和可观测性的团队。读完你会知道为什么错误页面不能随便写写,以及如何做出一个既能挽留用户、又能帮你定位问题的错误页体系。

1. 项目背景与需求解析:为什么错误页面值得单独做

1.1 默认错误页面的“原罪”

我见过很多团队,功能页面精雕细琢,但错误页面用的是框架默认模板。不是说默认的不能用,而是它有几个实际痛点:

第一,信息量完全不可控。默认 404 页面不会告诉用户“你从哪里来、可以去哪里”,用户除了点击浏览器返回按钮,没有任何引导。如果这个用户是第一次进入你的网站,大概率直接流失。

第二,无法传递品牌调性。一个服务中断、突然报错,这时候用户是最焦虑的。如果错误页能延续网站的语言风格和视觉体系,焦虑感会明显降低;如果突然换成浏览器默认样式,用户会怀疑“我是不是被带到了一个陌生网站”。

第三,排查问题困难。默认 500 页面只有一个通用文案,服务端异常的具体信息被吞掉了。开发人员在用户报障之后只能靠日志系统去反查,而一套好的自定义错误页面本身就能配合日志、错误上报,把发生异常的上下文信息结构化地记录下来。

这个项目的本质需求其实就是三件事:把 404 做成有引导的落地页、把 500 做成可反馈可重试的中转页、把错误信息变成可观测的数据源。

1.2 自定义错误页面的四个实际收益

从业务价值角度看,错误页面不是一个“做了就行”的边缘功能,它直接关联四个指标:

  • 用户留存:给出相似导航、推荐内容、搜索入口的 404 页,用户跳出率会明显低于只有一个“返回首页”链接的页面。
  • 用户信任:友好的错误提示加上明确的“我们已知悉”类文案,比冷冰冰的异常堆栈更能维护产品口碑。
  • 开发效率:错误页把 digest、statusCode、cause 等字段渲染出来,配合详情内容,报障沟通成本大幅降低。
  • 合规与安全:默认错误页在某些框架下会透露框架版本号、内部路径等敏感信息,自定义页面能完全过滤掉这些细节。

1.3 动手前必须分清的路由模式

Next.js 现在有两套路由体系:旧的 Pages Router 和新的 App Router。这也是新手最容易踩的第一个坑。

Pages Router 的目录是pages/,自定义错误的入口是404.js、500.js、_error.js;App Router 的目录是app/,自定义错误的入口是not-found.js、error.js、global-error.js。两个体系的文件命名、组件形态、数据获取方式差异很大,如果你混着用,很容易出现“我明明写了 404 页面为什么不管用”的情况。

我的建议是:新项目直接用 App Router,存量项目如果已经在 Pages Router 上稳定运行,也不必急着迁移,先把对应体系的错误页补齐才是当务之急。下面两个部分我会分别拆解这两套方案。

2. 核心原理拆解:Next.js 到底是怎么处理错误的

2.1 App Router 的“三件套”:error.js、not-found.js、global-error.js

App Router 下,错误处理遵循“就近捕获”原则,由三个文件配合完成:

not-found.js:处理两类情况。一类是访问了不存在的 URL 路径,框架自动渲染;另一类是你在服务端组件或路由处理程序里主动调用notFound()函数,手动触发 404 页面。这个文件可以是服务端组件,也可以加use client变成客户端组件,比较灵活。

error.js:处理路由段(segment)内部抛出的运行时错误,包括服务端组件里同步抛出的异常、客户端组件事件处理中的渲染异常。它必须是一个客户端组件,文件顶部必须有'use client'。它接收两个 prop:error和reset。error对象里包含错误信息和digest字段(一个哈希值,用于日志对账),reset是一个函数,调用后会在不刷新整个页面的情况下重渲染当前路由段。

global-error.js:处理根布局(root layout)里抛出的错误,以及error.js没有捕获到的、向上冒泡到根节点的错误。这个文件比较特殊,因为它会替换掉整个根布局,所以组件里必须自己包含<html>和<body>标签。

三者的捕获边界可以这样理解:not-found.js 处理“找不到”,error.js 处理“当前段出错了”,global-error.js 处理“连布局都炸了”的终极情况。

2.2 错误边界的嵌套逻辑

React 本身有 Error Boundary 机制,Next.js 在 App Router 里把它做了文件约定层面的封装。每个路由段都可以有自己的error.js,这个组件会包裹住该路由段下面的所有子页面和子布局。

嵌套规律是:最内层的 error.js 优先捕获。比如你有app/dashboard/和app/dashboard/analytics/两个层级,在 analytics 页面的数据获取里抛错,只有 analytics 目录下的 error.js 会触发;如果app/dashboard/layout.js本身抛错,那么 dashboard 目录下的 error.js 才能捕获。如果一直向上冒泡都没有对应的 error.js,最后会到 global-error.js。这个机制很重要,它意味着你可以针对不同业务模块做更精细的错误提示,而不是所有错误都走到同一个页面。

2.3 notFound() 与状态码的关系

notFound()是 Next.js 提供的一个服务端函数,调用后不仅会渲染not-found.js,还会向客户端发送真实的 404 状态码。这一点对 SEO 很关键,因为搜索引擎爬虫会依赖 HTTP 状态码判断页面是否存在,如果你返回 200 但内容显示“没找到”,搜索引擎会认为这是个软 404,长期影响收录质量。

另外注意一个细节:如果你在客户端组件里想触发 404,不能直接用notFound()(它是服务端函数),需要在服务端组件、服务端操作(Server Action)或路由处理器(Route Handler)里调用。客户端做不到就抛一个非 404 的异常,或者用redirect()路由到一个专门展示 404 的页面。

2.4 Pages Router 时代的传统机制

Pages Router 自定义错误的入口要简单很多:

  • pages/404.js:静态渲染,专门对应 404 状态码。
  • pages/500.js:静态渲染,专门对应 500 状态码。
  • pages/_error.js:动态错误页,可以处理任意状态码的错误,通过getInitialProps拿到statusCode后自行判断渲染内容。

Pages Router 这套方案的问题在于,_error.js里的错误处理比较粗糙——它能拿到状态码,但拿不到详细的错误对象。而且它也不支持错误边界这种细粒度的捕获方式,一个错误页管所有错误。这也是为什么 App Router 的“三件套”出来之后,我倾向于推荐新项目用它。

2.5 两套体系的完整对照

维度App RouterPages Router
404 直接页面app/not-found.jspages/404.js
500 直接页面app/error.js(客户端)pages/500.js
动态兜底页面app/global-error.jspages/_error.js
局部错误边界每个路由段一个 error.js不支持
主动触发 404服务端调用notFound()pages/_error.js内判断状态码
错误对象error、digest、resetstatusCode、err
布局修复支持,global-error 可重建 html/body不支持

3. 实操过程:App Router 下实现一套完整的错误页面体系

3.1 第一步:设计信息结构与视觉框架

在写代码之前,我的习惯是先决定错误页要展示什么信息。基础元素包括:状态码、错误标题、用户可读的说明文字、行动按钮(回首页、重试)、可选的错误追踪 ID。

这里有一个取舍问题:是否把error.message直接渲染给用户?我建议是永远不要。错误消息本身是给开发者看的,里面可能有数据库连接串、文件路径等敏感信息,直接展示在页面上不仅暴露内部结构,用户也完全看不懂。正确做法是把error.message和digest写入日志上报系统,页面上只展示一个脱敏的“追踪编号”,比如ERROR-20250512-8f3a91b2,用户反馈时把这个编号发给你,你就能在日志系统里快速定位。

3.2 第二步:实现 not-found.js

以app/not-found.tsx为例,一个实用的 404 页面长这样:

import Link from "next/link"; export default function NotFound() { return ( <main className="flex min-h-screen flex-col items-center justify-center gap-6 px-4 text-center"> <p className="text-6xl font-bold text-slate-300">404</p> <h1 className="text-2xl font-medium">你访问的页面已经搬迁或不存在</h1> <p className="max-w-md text-slate-500"> 你可以回到首页继续浏览,或者检查一下网址是否输入有误。如果这个问题一直出现, 建议把页面地址反馈给我们的值班同学。 </p> <div className="flex gap-4"> <Link href="/" className="rounded-md bg-slate-900 px-5 py-2.5 text-white"> 回到首页 </Link> <Link href="/search" className="rounded-md border px-5 py-2.5"> 去搜索 </Link> </div> </main> ); }

这个组件会在两种场景下渲染:路由根本不存在,或者你在某个页面里主动调用了notFound()。如果业务上有需要,你可以给这个文件加上use client,在 404 页面里做一些客户端交互。但我个人建议保持它是服务端组件,服务端渲染更快,也能享受流式传输的优化。

3.3 第三步:实现带重置能力的 error.js

app/error.tsx必须是客户端组件,代码结构上要注意三点:文件顶端必须有'use client'、组件签名接收{ error, reset }、不能包含<html>或<body>标签(那是 global-error.js 的职责)。

"use client"; import Link from "next/link"; import { useEffect } from "react"; export default function ErrorPage({ error, reset, }: { error: Error & { digest?: string }; reset: () => void; }) { useEffect(() => { // 这里把错误上下文发送到监控平台 console.error("路由段渲染异常", { message: error.message, digest: error.digest, path: window.location.pathname, time: new Date().toISOString(), }); }, [error]); return ( <main className="flex min-h-screen flex-col items-center justify-center gap-6 px-4 text-center"> <p className="text-6xl font-bold text-slate-300">500</p> <h1 className="text-2xl font-medium">页面开小差了</h1> <p className="max-w-md text-slate-500"> 我们的服务短暂遇到了问题,稍微等一下再试试。 </p> <p className="text-sm text-slate-400">追踪编号:{error.digest ?? "未知"}</p> <div className="flex gap-4"> <button onClick={reset} className="rounded-md bg-slate-900 px-5 py-2.5 text-white" > 重新加载 </button> <Link href="/" className="rounded-md border px-5 py-2.5"> 回到首页 </Link> </div> </main> ); }

reset函数的语义值得展开说:它不会重新刷新整个浏览器页面,而是让 React 重新渲染发生错误的那个路由段。这听起来很美好,但有一个坑——如果错误来源于某个外部服务的临时故障,比如接口超时,重渲染大概率还是会失败。所以更好的体验是,onClick里先调用reset(),同时设置一个标志位,如果再次触发同一个错误,就引导用户刷新整个页面。

3.4 第四步:实现兜底的 global-error.js

app/global-error.tsx是最后一道防线。它捕获的是根布局app/layout.tsx里的错误,也就是渲染骨架本身出错时的情况。因为根布局已经崩了,这个组件只能自己输出完整的 HTML 结构:

"use client"; export default function GlobalError({ error, reset, }: { error: Error; reset: () => void; }) { return ( <html lang="zh-CN"> <body> <main style={{ minHeight: "100vh", display: "flex", flexDirection: "column", alignItems: "center", justifyContent: "center", gap: "16px", fontFamily: "system-ui, sans-serif", }} > <h1>服务暂时不可用</h1> <button onClick={reset} style={{ padding: "10px 20px", borderRadius: "8px", border: "1px solid #ccc", background: "#fff", cursor: "pointer", }} > 重试 </button> </main> </body> </html> ); }

注意两点:根布局里引用的全局样式(比如 Tailwind 的globals.css)在 global-error.js 里加载不到,因为 root layout 已经失效了。所以这个文件里尽量用内联样式或者简单的<style>标签,别依赖外部样式表。另外这个文件也接收error和reset,但它的reset语义更弱一些,我实际测试下来,触发全局错误后reset能成功的情况非常少,通常还是走整页刷新。

3.5 第五步:处理嵌套路由的局部错误

在一个中大型项目里,你大概率不会只写一个顶层 error.js,而是按模块拆分。比如订单中心和用户中心是两个完全不同的业务域,用户在订单列表页遇到网络错误时,希望看到的文案是“订单数据加载失败”,用户中心则是“账号信息同步失败”。做法很简单,在对应路由目录下各自新建一个error.js即可。

还要注意一个细节:loading.js和error.js是配套存在的。错误如果出现在服务端数据获取阶段,Next.js 会先展示loading.js(如果在相同路由段定义了),然后异步渲染完成后再决定是展示页面还是触发error.js。这个机制能避免用户面对一个长时间空白屏幕,体验上比直接白屏要好得多。

3.6 样式适配:让错误页融入整体设计

错误页最容易犯的设计错误就是“用力过猛”。我见过不少团队把动漫插画、大型动画塞进 404 页面,结果因为动画库加载失败反而触发二次错误。

稳健的样式方案包含这几个原则:纯静态视觉(不要依赖 JS 动画库)、使用全局已有的 CSS 变量和设计 token、保证高对比度文案、移动端单列布局。如果你在用 Tailwind,直接沿用项目里的工具类就行,错误页也是常规模板的一部分,不属于需要特殊隔离的场景。

暗色模式适配要特别注意。如果你的站点支持明暗主题,错误页里的自定义色值务必和主题系统打通,否则用户切到暗色模式后再访问 404 页面,会出现一个刺眼的白色错误块。

4. 兼容方案:Pages Router 下的自定义错误页实现

4.1 简单的 404 静态页面

如果你的项目还在用 Pages Router,方案文件结构如下。首先是pages/404.js:

import Link from "next/link"; export default function Custom404() { return ( <div style={{ textAlign: "center", padding: "80px 20px" }}> <h1>404</h1> <p>页面不存在或已被移除。</p> <Link href="/">回到首页</Link> </div> ); }

这个文件在构建时就被静态化了,性能很好。但它的局限在于,所有 404 都走同一个页面,无法按模块区分。

4.2 动态兜底页面 _error.js

pages/_error.js的作用更像一个动态分发器。它会在项目抛出任意未捕获错误时被调用,通过getInitialProps拿到statusCode和err对象:

function ErrorPage({ statusCode }) { return ( <div style={{ textAlign: "center", padding: "80px 20px" }}> <h1>{statusCode || "客户端错误"}</h1> <p> {statusCode ? `服务端返回了错误状态码 ${statusCode}` : "页面在客户端渲染时出现了未知问题"} </p> <a href="/">回到首页</a> </div> ); } ErrorPage.getInitialProps = async ({ res, err }) => { const statusCode = res ? res.statusCode : err ? err.statusCode : 404; return { statusCode }; }; export default ErrorPage;

这里有个坑:Pages Router 会把_error.js用于所有错误,包括服务端渲染错误和客户端渲染错误。客户端侧的err对象往往信息不全,所以getInitialProps里的兜底逻辑必须健壮。另外_error.js本身也会被用于 404 和 500,如果你同时定义了404.js和500.js,它们会优先于_error.js被使用。

4.3 Pages Router 与 App Router 的取舍建议

从工程实践角度,我对两套方案的使用推荐是:新项目无脑 App Router;老项目如果只是简单静态站,Pages Router 的404.js+500.js完全够用;如果老项目已经深度使用getServerSideProps、有复杂的自定义服务器逻辑,迁移 App Router 的成本需要单独评估,不必为了错误页而强行迁移。

但无论哪套方案,错误页的“状态码正确性”都是底线。我用一个简单命令就能检查线上部署是否正常:

curl -o /dev/null -w "%{http_code}" https://你的域名/一个不存在的路径

期望输出是404,如果输出200,说明错误页没有走框架的 404 逻辑,而是被某个中间件、重写规则或者自定义服务器吞掉了,这种情况必须排查。

5. 常见问题与排查技巧实录

5.1 error.js 没生效,页面直接白屏

这种状况我遇到好几次,每次都是同一个原因:文件顶部忘记写'use client'。error.js的本质是一个 React Error Boundary,Error Boundary 只能存在于客户端组件体系中。如果你把它写成服务端组件,Next.js 不会报编译错误,但运行时它无法捕获错误,最终就会走到 global-error.js。

排查方法:先看浏览器控制台有没有报错,再看代理网络面板里返回的状态码。如果状态码是 500 但页面显示的是 global-error 的内容,那就是 error.js 所在的层级不对,或者根本缺失。

5.2 global-error.js 样式全乱

global-error.js会替换根布局,所以它自己必须包含<html>和<body>标签。但全局样式文件globals.css是在根布局里被 import 的,根布局崩了,样式自然也就不在了。

我的经验是:在global-error.js里不要依赖任何外部样式,全部用内联样式或者内置一个极简<style>标签。虽然代码看起来没那么优雅,但它是最后兜底环——兜底的东西要求的就是简单、可靠、不依赖外部资源。

5.3 点击“重新加载”后还是同一个错误

reset()重新渲染路由段时,并不会重置 React 组件的本地状态。如果错误发生在某个客户端组件的useEffect里,而这个组件的依赖数组没变化,那么重渲染后可能立刻再次触发相同错误。

我处理这种问题时会在error.tsx里做一版“失败计数”:

const [retryCount, setRetryCount] = useState(0); async function handleReset() { if (retryCount >= 1) { window.location.reload(); return; } setRetryCount((c) => c + 1); reset(); }

这个思路就是“给重试限次”,第一次失败后按钮文案改成“刷新页面”,引导用户整页重新加载,避免在两个错误页之间反复横跳。

5.4 404 页面的 HTTP 状态码不是 404

出现这种情况,常见于你在app/not-found.js里写了一个会执行跳转的客户端逻辑,或者你的next.config.js里配置了rewrites规则,把不存在的路径重写到了某个页面。另一个常见原因是中间件(middleware)里对路径做了兜底处理,直接返回页面内容。

验证方法是先临时注释掉所有 rewrite 和 middleware 逻辑,再测试一个不存在的深层路径,例如/a/b/c/nonexist。如果这时状态码正确变成 404,那问题就在重写规则或中间件逻辑上。

5.5 局部 error.js 一直不触发,报错总是跑到顶层

如果你的子路由目录下已经有error.js,但错误还是冒泡到了app/error.js,排查顺序如下:

  • 子目录的error.js是否出现在正确的分段目录下,不是子页面里的page.js(page 文件自身不能命名为 error)。
  • 子目录的page.tsx是否没有自己执行数据获取,反而在布局layout.tsx里做异步请求——布局抛错会冒泡到布局所在段的 error.js,而不是页面所在段的。
  • 子目录的error.js里是否也抛出了异常,错误边界自己出错了会继续向上传播。

注意:error.js 组件本身如果抛错,会直接跳过同一层次的捕获,向上层冒泡。所以写 error.js 的时候,组件内部逻辑要格外简单,不要在里面做网络请求、状态管理等复杂操作。

5.6 需要后台管理的“错误上报”怎么做

错误页只是前端展示层,真正的价值在于和日志系统联动。在app/error.tsx的useEffect里向监控平台发送事件时,至少包含以下字段:error.message(原始错误)、error.digest(框架生成的追踪 ID)、pathname(当前路径)、renderTime(渲染时间戳)、source(标识来自 error.js 还是 global-error.js)。

error.digest这个字段非常实用,它是 Next.js 服务端错误和客户端错误共用的哈希标识。服务端日志和前端上报通过 digest 关联,用户报障时只需要把页面上的“追踪编号”发给你,你就能在日志平台里精确搜索到当时完整的错误堆栈。

6. 后续可以怎么扩展这套方案

我做这一套错误页体系时,最后临时加的几样东西反而成了最常用的部分。第一个是“健康检查专用路径”,在平台做可用性监控时,走独立路由判断整个应用的基础依赖是否可用,这个和用户侧的 404/500 页面互不干扰,但对线上稳定性判断很有帮助。

第二个是“错误页 A/B 文案池”,不同业务状态下,用户需要的引导不同。比如大促期间 404 页面引导到活动主会场,平时引导到搜索页。这个用not-found.js里加一个简单的服务端配置读取就能实现,不需要为了文案变化频繁发版。

第三个是“CSP 兼容性检查”,在做内容安全策略时,错误页展示的内联样式、上报脚本的域名白名单都容易成为漏网之鱼。我踩过一次坑,错误页上报接口被 CSP 拦截,结果就是页面正常显示了,但日志里完全没有错误记录。上线前用浏览器控制台看一眼有没有 CSP 违规提示,这个动作应该写进发布清单里。

根据我在实际项目里的经验,自定义错误页面这件事最容易被低估,但它的性价比其实极高——写一个页面用的时间不过半天,换来的是用户流失降低、线上问题定位提速、以及品牌调性在所有场景下的统一。你如果正在规划这个功能,我的建议是先做通 App Router 的“三件套”,再补上全局错误上报日志字段,这两个完成之后,这套体系基本就立住了。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进