资讯详情

资讯详情

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.jsApp 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.jsApp Router 下错误处理遵循“就近捕获”原则由三个文件配合完成not-found.js处理两类情况。一类是访问了不存在的 URL 路径框架自动渲染另一类是你在服务端组件或路由处理程序里主动调用notFound()函数手动触发 404 页面。这个文件可以是服务端组件也可以加use client变成客户端组件比较灵活。error.js处理路由段segment内部抛出的运行时错误包括服务端组件里同步抛出的异常、客户端组件事件处理中的渲染异常。它必须是一个客户端组件文件顶部必须有use client。它接收两个 properror和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 Router404 直接页面app/not-found.jspages/404.js500 直接页面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 classNameflex min-h-screen flex-col items-center justify-center gap-6 px-4 text-center p classNametext-6xl font-bold text-slate-300404/p h1 classNametext-2xl font-medium你访问的页面已经搬迁或不存在/h1 p classNamemax-w-md text-slate-500 你可以回到首页继续浏览或者检查一下网址是否输入有误。如果这个问题一直出现 建议把页面地址反馈给我们的值班同学。 /p div classNameflex gap-4 Link href/ classNamerounded-md bg-slate-900 px-5 py-2.5 text-white 回到首页 /Link Link href/search classNamerounded-md border px-5 py-2.5 去搜索 /Link /div /main ); }这个组件会在两种场景下渲染路由根本不存在或者你在某个页面里主动调用了notFound()。如果业务上有需要你可以给这个文件加上use client在 404 页面里做一些客户端交互。但我个人建议保持它是服务端组件服务端渲染更快也能享受流式传输的优化。3.3 第三步实现带重置能力的 error.jsapp/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 classNameflex min-h-screen flex-col items-center justify-center gap-6 px-4 text-center p classNametext-6xl font-bold text-slate-300500/p h1 classNametext-2xl font-medium页面开小差了/h1 p classNamemax-w-md text-slate-500 我们的服务短暂遇到了问题稍微等一下再试试。 /p p classNametext-sm text-slate-400追踪编号{error.digest ?? 未知}/p div classNameflex gap-4 button onClick{reset} classNamerounded-md bg-slate-900 px-5 py-2.5 text-white 重新加载 /button Link href/ classNamerounded-md border px-5 py-2.5 回到首页 /Link /div /main ); }reset函数的语义值得展开说它不会重新刷新整个浏览器页面而是让 React 重新渲染发生错误的那个路由段。这听起来很美好但有一个坑——如果错误来源于某个外部服务的临时故障比如接口超时重渲染大概率还是会失败。所以更好的体验是onClick里先调用reset()同时设置一个标志位如果再次触发同一个错误就引导用户刷新整个页面。3.4 第四步实现兜底的 global-error.jsapp/global-error.tsx是最后一道防线。它捕获的是根布局app/layout.tsx里的错误也就是渲染骨架本身出错时的情况。因为根布局已经崩了这个组件只能自己输出完整的 HTML 结构use client; export default function GlobalError({ error, reset, }: { error: Error; reset: () void; }) { return ( html langzh-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.jsimport Link from next/link; export default function Custom404() { return ( div style{{ textAlign: center, padding: 80px 20px }} h1404/h1 p页面不存在或已被移除。/p Link href/回到首页/Link /div ); }这个文件在构建时就被静态化了性能很好。但它的局限在于所有 404 都走同一个页面无法按模块区分。4.2 动态兜底页面 _error.jspages/_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.js500.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 BoundaryError 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.jspage 文件自身不能命名为 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 的“三件套”再补上全局错误上报日志字段这两个完成之后这套体系基本就立住了。
觉得有用,分享给同行:

为您的企业打造数字门面

稳重轻奢商务风格,端正雅致视觉,长效耐看不易过时。

立即咨询 →