接到一个 Next.js 项目时我有个习惯先随机打开几个根本不存在的 URL看看默认的 404 页面长什么样。如果看到的是白底黑字的 This page could not be found多半能猜到团队对异常链路的态度——不是不重视而是压根没想过错误页面还要设计。自定义错误页面在 Next.js 里是个小而关键的点它直接暴露了产品对异常场景的处理深度。这篇文章不聊虚的围绕 Next.js 中自定义错误页面这件事把我在不同项目里踩过的坑、做过的取舍、以及从 Pages Router 切到 App Router 后整套机制的变化一次说清楚。1. 错误页面这件事为什么值得单独设计1.1 错误页面的价值远不止好看很多团队把 404 当成摆设觉得反正用户也不会看。这个想法在流量小的内部工具里可能无所谓但只要产品面向真实用户错误页面就是一条非常关键的用户挽回通道。第一个理由是品牌信任。用户访问一个不存在的链接看到精致的插画文案和返回入口会认为是产品考虑到了我的处境看到裸奔的浏览器默认样式第一反应是网站坏了。这两者的差距直接决定了用户是继续逛还是关掉标签页。第二个理由是 SEO。404 页面必须返回真实的 404 状态码搜索引擎才能正确识别死链并把它从索引里摘除。如果因为实现不当导致页面返回 200爬虫会认为这是一个有效页面反复抓取浪费抓取预算不说还会让死链长期残留在搜索结果里。第三个理由是监控。一个合格的自定义错误页面通常会带上简单的埋点代码。这样产品侧可以统计哪个旧链接还在被访问、用户从哪个入口走进了 404这些数据反推出来的是导航重构、内容迁移的优先级。没有自定义错误页面这套数据基本拿不到。这些理由叠加起来你会发现错误页面从来不是锦上添花的活儿它是产品基本功的一部分。1.2 两套路由体系两套约定说 Next.js 的当前版本就必须面对一个现实你手里的项目可能是 Pages Router也可能是 App Router两者的错误页面机制完全不兼容。Pages Router 时代约定文件是pages/404.js和pages/_error.js。前者专门负责 404后者是兜底错误页负责 500 以及各种非预期异常。你还可以在pages/_error.js里通过getInitialProps读取状态码做差异化渲染。App Router 时代约定文件变成了app/not-found.js、app/error.js和app/global-error.js。not-found.js管 404error.js是 React 错误边界负责捕获渲染异常并展示可恢复的错误提示global-error.js则是根布局层面的最终兜底。这套变化不是简单改名而是底层模型变了。Pages Router 的错误处理建立在页面级约定上错误页就是特殊页面App Router 把错误处理纳入了组件树边界的逻辑里错误页变成了挂在 React 树上的特殊节点。理解这个差异后面写代码才不会一头雾水。场景Pages RouterApp Router404 约定文件pages/404.jsapp/not-found.js500 兜底文件pages/_error.jsapp/error.jsapp/global-error.js是否需要服务端数据_error.js可用getInitialPropserror.js必须是客户端组件根布局出错兜底_error.jsapp/global-error.js1.3 明确边界自定义错误页面能做什么不该做什么我见过有人把 404 页面做成了产品首页的复刻版导航栏、登录态、推荐位全往上堆。问题在于错误页面出现的场景往往是资源缺失、会话异常、服务降级此时用户最需要的是我能去哪不是你有什么。比较合理的能力范围是这么几条展示状态码和友好文案让用户知道发生了什么提供回首页、回上一页、站内搜索等低门槛引导承担轻量级埋点统计错误来源和访问路径对有品牌调性的产品延续视觉语言不让用户出戏。不该做的是这些依赖登录态展示个性化内容——用户会话失效时错误页本身可能无法正常渲染在错误页里发起多个高概率失败的请求——每个失败都会成为新的监控噪音把错误页做成需要动态数据的复杂页面——它越简单出问题的概率越低。把这条边界记住后面所有设计决策都会清晰很多。2. 核心机制状态码从哪来错误由谁处理2.1 Pages Router404.js 与 _error.js 的分工逻辑Pages Router 的约定特别直白你在pages目录下建一个404.js等于告诉 Next.js所有没匹配到路由的请求都渲染这个页面。这个文件不需要任何额外配置最简单的情况长这样// pages/404.js export default function Custom404() { return ( div classNamenot-found-container h1404/h1 p你访问的页面不存在可能是链接失效也可能内容已经搬家。/p a href/返回首页/a /div ) }这个组件会被编译成一个静态页面用户在服务端直接访问或刷新时响应头里的状态码就是 404。这一点很重要它不是看起来像 404 的页面而是真的会返回 404 状态码。那_error.js呢Pages Router 里没有500.js这个约定文件500 以及其它异常统一走_error.js。它的标准写法需要结合getInitialProps// pages/_error.js function ErrorPage({ statusCode, message }) { return ( div classNameerror-container h1{statusCode ? 出错了${statusCode} : 页面加载失败}/h1 p{message || 服务暂时无法响应该请求请稍后重试。}/p a href/返回首页/a /div ) } ErrorPage.getInitialProps ({ res, err }) { const statusCode res ? res.statusCode : err ? err.statusCode : 404 return { statusCode } } export default ErrorPage关键在getInitialProps的返回值来源服务端渲染场景下res.statusCode直接取服务端响应状态比如后端接口挂了上游返了 502Nginx 或 Next.js 服务层会把状态透传过来纯客户端路由跳转时res是undefined这时候只能从err上拿err.statusCode拿不到就兜底成 404加一个message字段是为了在已知错误场景下展示具体原因避免用户只看到一串数字。注意项目里只要存在pages/404.js绝大多数 404 就不会再进_error.js。_error.js实际管的是 500 和那些没有专门约定文件的异常别把两件事搞混。2.2 App Routernot-found.js、error.js、global-error.js 三层结构App Router 把错误处理做成了三层每一层的职责和触发条件都不一样理解这三层就能看懂整套体系。第一层是app/not-found.js对应 404。它有两种触发方式一是访问了完全没有匹配的路径Next.js 自动渲染二是页面代码里主动调用notFound()比如某个详情页请求数据时发现记录不存在调用notFound()后 Next.js 会渲染最近一层定义的not-found.js。注意notFound()需要从next/navigation导入而且它会告诉服务端返回 404 状态码。第二层是app/error.js对应 React 错误边界。App Router 允许每个路由层级定义自己的error.js它负责捕获该层级渲染时抛出的异常。这个文件有个硬性要求必须是客户端组件也就是文件顶部要写use client。它接收两个参数error是错误对象reset是重置函数调用后 Next.js 会尝试重新渲染边界内的内容。典型场景是页面里的某个数据请求崩了用户点重试按钮页面重新加载问题可能就过去了。第三层是app/global-error.js对应根布局的兜底。当错误发生位置在根布局或根模板时error.js是捕获不到的这时候global-error.js上场。它同样必须是客户端组件而且因为它替代的是整个根布局组件内部必须自己带上html和body标签。三层之间的关系可以这样理解not-found.js是正常业务中的资源没找到error.js是某个子树的运行时异常global-error.js是整个应用的最后一道防线。层级越靠下越要简洁。2.3 状态码、渲染时机和爬虫行为自定义错误页面最常见的一个认知误区是把页面内容和HTTP 状态码当成一回事。其实 Next.js 在服务端直出场景下会自动处理状态码访问未匹配路由时返回 404后端异常时返回 500这些状态码会真实出现在响应头里。爬虫看到的就是这些状态码所以 SEO 对自定义 404 页面几乎无感只要你别干扰 Next.js 默认的状态码行为就行。但有一个坑很容易踩客户端导航。用户在页面里点击某个通向不存在路径的链接时Next.js 会在客户端完成路由切换并渲染not-found.js或404.js浏览器地址栏变成了目标地址但整个过程中没有新请求发送到服务端页面最后拿到的很可能是 200 而不是 404。只有用户手动刷新服务端收到新请求才会返回 404。这就是为什么有些监控平台统计到的 404 数量会忽高忽低——统计口径不同结果完全不同。还有一个相关细节通过next/link做的客户端跳转如果目标页面是 catch-all 路由而你又在里面手动渲染了404 样式服务端根本不知道你要返回 404状态码就是 200。正确的做法是让代码调用notFound()或使用pages/404.js/app/not-found.js这种约定式文件而不是自己画一个假 404。所以在做错误页监控时别只盯着客户端埋点应该结合服务端访问日志看真实的 4xx、5xx 分布覆盖面才完整。3. 实操从默认错误页到完整错误处理体系3.1 Pages Router 快速落地404.js _error.js 的组合先建pages/404.js这个页面不需要任何数据请求直接写静态组件。我习惯用 CSS Modules 把样式和页面绑定避免全局样式污染正常页面// pages/404.js import styles from ./404.module.css export default function Custom404() { return ( div className{styles.wrap} div className{styles.code}404/div p className{styles.desc} 你要找的页面可能被移动、删除或者从来没有存在过。 /p a className{styles.link} href/回首页看看/a /div ) }/* pages/404.module.css */ .wrap { min-height: 70vh; display: flex; flex-direction: column; align-items: center; justify-content: center; text-align: center; padding: 32px 16px; } .code { font-size: 64px; font-weight: 700; color: #666; } .desc { margin-top: 16px; color: #999; line-height: 1.7; } .link { margin-top: 24px; display: inline-block; padding: 10px 24px; border-radius: 6px; background: #333; color: #fff; text-decoration: none; }这就算完成了一个可用 404。要注意的是404.js 里的a我故意用了普通a而不是next/link因为错误页尽量保持简单直接走原生 HTML 语义最稳。接下来补pages/_error.js。我给上面的基础版加一个message处理// pages/_error.js const ERROR_MESSAGES { 400: 请求方式有误已上报给工程师。, 401: 需要登录后才能访问该页面。, 403: 你没有权限查看该内容。, 404: 页面不存在或已被移动。, 500: 服务器内部错误正在抢修中。, } function ErrorPage({ statusCode }) { const message ERROR_MESSAGES[statusCode] || 出了一点问题请稍后重试。 return ( div classNameerror-page h1{statusCode || Error}/h1 p{message}/p {statusCode 500 a href/刷新首页重试/a} /div ) } ErrorPage.getInitialProps ({ res, err }) { const statusCode res ? res.statusCode : err ? err.statusCode : 404 return { statusCode } } export default ErrorPage这里把常见的状态码文案收敛在一个字典里。加一个提示_error.js里的getInitialProps是唯一的官方数据获取方式你不能在_error.js上用getStaticProps或getServerSideProps这在 Pages Router 里有明确限制。原因很简单错误页需要在错误场景下保证启动成功率再依赖一套完整的数据流反而是负担。3.2 App Router 落地not-found.js、error.js、global-error.js 一次配齐App Router 里的三个文件我一般习惯同时建好别只做一个 404。先写app/not-found.js// app/not-found.js export const metadata { title: 页面不存在, description: 你访问的内容不存在或已被移动。, } export default function NotFound() { return ( div classNamenot-found h1404/h1 p没找到这页。可能是链接写错了也可能是内容已经下架。/p a href/返回首页/a /div ) }这个文件可以导出metadata对 SEO 友好。在 App Router 中not-found.js可以嵌套在任意路由层级比如某个博客路由app/posts/[slug]/not-found.js可以只对该详情页生效。但最常用的是放在根app目录做全局 404。再看app/error.js它是客户端组件核心逻辑是错误上报和重置use client import { useEffect } from react export default function Error({ error, reset }) { useEffect(() { // 这里做埋点上报比如记录 error.message 和错误堆栈 console.error(页面渲染异常:, error) }, [error]) return ( div h2页面暂时无法显示/h2 p请求在处理过程中遇到异常这通常是临时性问题。/p button onClick{reset}重新加载/button /div ) }reset按钮的作用不复杂点击后 React 会重新渲染错误边界内部的内容。常见情形是后端服务抖动导致接口超时用户点一下重试就好了犯不着跳到首页再逛一圈回来。最后是app/global-error.js。注意它和error.js的差别作用对象是根布局且必须手动渲染html和bodyuse client export default function GlobalError({ error, reset }) { return ( html langzh-CN body div h2应用遇到致命错误/h2 button onClick{reset}重试/button /div /body /html ) }放一个总结如果错误发生在app/layout.js之外或更上层走到global-error.js如果发生在某个页面、布局或路由段的组件里走error.js如果是资源缺失走not-found.js。这套结构理解到位就不会出现根布局崩了但 error.js 不生效的疑难杂症。3.3 给错误页面加上埋点和用户反馈错误页面本身是很好的监控点位。我常用的方案是在not-found.js或404.js里用useEffect上报一条访问记录。App Router 的not-found.js默认是服务端组件如果要加客户端埋点就得先包一层客户端容器// app/not-found.js import NotFoundClient from ./NotFoundClient export default function NotFound() { return NotFoundClient / }// app/NotFoundClient.js use client import { useEffect, useState } from react import { usePathname } from next/navigation export default function NotFoundClient() { const pathname usePathname() const [referrer, setReferrer] useState() useEffect(() { setReferrer(document.referrer) }, []) useEffect(() { // 上报访问记录建议带上 URL 和来源 if (typeof window ! undefined window.console) { console.info([404], pathname, document.referrer) } }, [pathname, referrer]) return ( div h1404/h1 p没找到你访问的内容。/p a href/回首页/a /div ) }这里有几个经验点使用usePathname可以拿到真实路径方便后续统计哪些死链流量最多document.referrer在服务端为空要在客户端获取所以用useState配合useEffect更新埋点请求记得加try/catch别让统计接口失败反过来影响错误页本身。更进阶的做法是在 404 页面展示最近访问过的页面列表。这可以从localStorage读取给用户一点熟悉感降低跳出率。但这个功能要严格控制复杂度读不到缓存就直接不显示绝不能让它变成用户访问错误页时的第二个错误源。3.4 静态导出和托管平台的特殊处理如果你的 Next.js 项目配置了output: export整个站点会在构建期输出成静态 HTML。此时 Pages Router 的自定义 404 会被编译成404.htmlApp Router 的not-found.js也会产出对应的静态 404 页面。但有个绕不开的问题静态托管平台的默认行为不完全一致。有些平台对未匹配路径直接返回默认 404 页面压根不管你的404.html另一些平台支持_redirects或vercel.json之类的配置文件把所有未知路径重写到404.html或not-found页面。所以用静态导出方案时除了把页面写好还要去托管平台后台配一条通配路由指向 404 页面的规则。这一步漏了你的自定义 404 在线上就是不生效的。500 错误页在纯静态站点里基本派不上用场因为服务端不参与渲染。但如果是混合部署前端页面托管在静态平台、接口走服务端那么接口异常时返回的 500 并不会触发 Next.js 错误页而是需要前端业务代码里做响应码判断。这个场景不属于框架层面能解决的问题要自己在请求层统一拦截。4. 常见问题与排查实录4.1 自定义 404 不生效按这个顺序排查遇到我明明写了 404 页面但就是不显示的问题不要慌按下面的顺序排查最快检查文件路径Pages Router 必须是pages/404.jsApp Router 必须是app/not-found.js。放错目录、拼错文件名都是最常见的问题。检查是否存在同名页面拦截如果你在 Pages Router 里建了一个动态路由pages/[slug].js而且没有在内部调用notFound()那么所有不存在的路径都是 200404 页面永远没机会展示。这是最容易忽略的。检查构建缓存修改错误页后有时候本地没问题但线上不更新把.next目录删掉重新 build。检查平台规则部署平台是否有自定义错误页配置覆盖了你的页面。比如有些平台要求上传专门的404.html文件和 Next.js 构建产物冲突。4.2 error.js 没有触发多半是层级没对上很多人写完error.js后发现某个页面抛错并没有走到这个文件里。第一个嫌疑点是错误发生在app/layout.js或者app/template.js而这些地方不在error.js边界之内必须用global-error.js。第二个嫌疑点是页面用的不是客户端组件。这里有个容易绕晕的设计App Router 页面本身可以是服务端组件error.js是客户端组件它会包裹住服务端渲染结果。当服务端组件抛错时其实也会被最近的error.js捕获。但如果页面在客户端交互阶段抛错而错误边界没有正确包裹对应组件树也不会触发。实践中的经验是error.js一定要放在路由段的最顶层比如app/(site)/error.js可以覆盖该路由组下的所有页面而不是只放在具体业务目录里。还有个开发环境的坑改完error.js热更新偶尔不生效本地刷新也会继续显示旧的错误内容。这时候停掉 dev server、删.next缓存重新启动保准正常。4.3 not-found.js 区分不了具体缺失资源换一个思路notFound()在 App Router 中不接受自定义参数。也就是说无法直接向not-found.js传递是文章不存在还是商品不存在之类的信息。页面只能统一显示404。我在一个内容平台项目里遇到过这个需求用户访问一篇被删除的文章希望看到该文章已下架可能因违规被移除而访问一个随机乱写的 URL只需要看到普通 404。这里用约定式not-found.js做不了我的做法是改用 catch-all 路由拦截并自己做差异化渲染// app/[...slug]/page.js import { notFound } from next/navigation async function getContentBySlug(slug) { // 请求数据返回 null 表示资源不存在 const data await fetch(/api/content?slug${slug}) if (!data.ok) return null return data.json() } export default async function Page({ params }) { const content await getContentBySlug(params.slug) if (!content) { notFound() } // 正常渲染内容 return div{/* content */}/div }然后利用嵌套not-found.js做层级区分app/[...slug]/not-found.js放内容不存在可能已删除的文案app/not-found.js放全局兜底。虽然不能传参但通过路由层级能做出足够细的区分对用户来说体验差别很大。4.4 避免错误页面变成新的错误源错误页面要能在最恶劣的环境下工作。我在实践中列过几张避坑清单每条都是真实踩过的不要在not-found.js里再次调用notFound()这会让 Next.js 进入循环匹配轻则白屏重则请求死循环埋点请求不要放在阻塞渲染的位置更不要同步等待上报结果用 fire-and-forget 的方式发出去就完事错误页不要依赖第三方字体或外部图片拦截页面里的 CSS 或图片挂了错误页也会变得很难看甚至不可用在error.js里打印错误日志时做好环境判断生产环境不要直接把完整的堆栈暴露给用户一是安全问题二是用户根本看不懂错误页上的链接保持最小集回首页、回上一页、刷新重试三选二就够别放一排导航。最后一句话总结我的态度错误页面是产品体验的兜底机制更是工程质量的试金石。你愿意花多少心思在用户最不爽的那个瞬间用户是能感觉到的。与其上线后补救不如开项目第一天就把404.js和not-found.js写好这个好习惯能帮你省掉后面无数个解释成本。