接到一个 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管 404,error.js是 React 错误边界,负责捕获渲染异常并展示可恢复的错误提示,global-error.js则是根布局层面的最终兜底。
这套变化不是简单改名,而是底层模型变了。Pages Router 的错误处理建立在"页面级约定"上,错误页就是特殊页面;App Router 把错误处理纳入了"组件树边界"的逻辑里,错误页变成了挂在 React 树上的特殊节点。理解这个差异,后面写代码才不会一头雾水。
| 场景 | Pages Router | App Router |
|---|---|---|
| 404 约定文件 | pages/404.js | app/not-found.js |
| 500 兜底文件 | pages/_error.js | app/error.js+app/global-error.js |
| 是否需要服务端数据 | _error.js可用getInitialProps | error.js必须是客户端组件 |
| 根布局出错兜底 | _error.js | app/global-error.js |
1.3 明确边界:自定义错误页面能做什么,不该做什么
我见过有人把 404 页面做成了产品首页的复刻版,导航栏、登录态、推荐位全往上堆。问题在于:错误页面出现的场景往往是资源缺失、会话异常、服务降级,此时用户最需要的是"我能去哪",不是"你有什么"。
比较合理的能力范围是这么几条:
- 展示状态码和友好文案,让用户知道发生了什么;
- 提供回首页、回上一页、站内搜索等低门槛引导;
- 承担轻量级埋点,统计错误来源和访问路径;
- 对有品牌调性的产品,延续视觉语言,不让用户出戏。
不该做的是这些:
- 依赖登录态展示个性化内容——用户会话失效时错误页本身可能无法正常渲染;
- 在错误页里发起多个高概率失败的请求——每个失败都会成为新的监控噪音;
- 把错误页做成需要动态数据的复杂页面——它越简单,出问题的概率越低。
把这条边界记住,后面所有设计决策都会清晰很多。
2. 核心机制:状态码从哪来,错误由谁处理
2.1 Pages Router:404.js 与 _error.js 的分工逻辑
Pages Router 的约定特别直白:你在pages目录下建一个404.js,等于告诉 Next.js"所有没匹配到路由的请求都渲染这个页面"。这个文件不需要任何额外配置,最简单的情况长这样:
// pages/404.js export default function Custom404() { return ( <div className="not-found-container"> <h1>404</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 className="error-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直接取服务端响应状态,比如后端接口挂了,上游返了 502,Nginx 或 Next.js 服务层会把状态透传过来; - 纯客户端路由跳转时,
res是undefined,这时候只能从err上拿err.statusCode,拿不到就兜底成 404; - 加一个
message字段是为了在已知错误场景下展示具体原因,避免用户只看到一串数字。
注意:项目里只要存在
pages/404.js,绝大多数 404 就不会再进_error.js。_error.js实际管的是 500 和那些没有专门约定文件的异常,别把两件事搞混。
2.2 App Router:not-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 className="error-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 className="not-found"> <h1>404</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>和<body>:
'use client' export default function GlobalError({ error, reset }) { return ( <html lang="zh-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> <h1>404</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.html,App 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.js,App Router 必须是app/not-found.js。放错目录、拼错文件名都是最常见的问题。 - 检查是否存在同名页面拦截:如果你在 Pages Router 里建了一个动态路由
pages/[slug].js,而且没有在内部调用notFound(),那么所有不存在的路径都是 200,404 页面永远没机会展示。这是最容易忽略的。 - 检查构建缓存:修改错误页后有时候本地没问题但线上不更新,把
.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写好,这个好习惯能帮你省掉后面无数个解释成本。