做 Next.js 项目做到一定规模,最先让你难受的往往不是业务逻辑,而是目录结构。页面一多,布局一嵌套,你会发现“文件放哪、URL 叫什么、布局怎么套”这三件事被死死绑在一起:想让/about这个 URL 干净一点,就得把页面文件挪来挪去;想给某几个页面单独套一个布局,又被迫改变它们的父级目录。Next.js 在 App Router 里给了一个专门解决这件事的方案,那就是路由组(Route Groups)。简单说,它允许你用括号目录(比如(marketing))来组织文件,而这个目录不会出现在 URL 里。这篇文章就围绕路由组展开,讲清楚它的核心机制、在一个多板块项目里的落地方式、进阶用法,以及我实际踩过的坑,适合正在用 App Router 做中大型站点、被目录结构调整反复折磨的开发者参考。
1. 路由组到底解决了什么问题
1.1 目录结构和 URL 从来没有分开过
先回到最朴素的问题上来。在 App Router 里,路由的规则极度直白:文件夹嵌套决定 URL 层级,page.js就是页面。拿一个典型的小站举例:
app/ ├─ layout.js ├─ page.js ├─ about/ │ └─ page.js ├─ blog/ │ ├─ page.js │ └─ [slug]/ │ └─ page.js └─ contact/ └─ page.js这套结构对应的 URL 非常直观:/是首页,/about是关于页,/blog和/blog/某篇文章是博客。目录怎么摆,URL 就是什么。这个观念在项目初期非常爽,因为不用记任何映射关系,看到文件路径就知道线上路径。
但项目一旦变大,这个“直觉”就开始背叛你。假设我维护的站点里,about、blog、contact这三个页面希望共用一套营销导航,而首页page.js是另一个完全独立的风格。按照目录即路由的规则,最直接的方案是新建一个叫marketing的父文件夹,把三个页面都塞进去,再在marketing下放一个layout.js。可是这样做的代价立刻出现:/about会变成/marketing/about,/blog会变成/marketing/blog。
URL 是给用户、搜索引擎、分享链接看的,产品希望它保持简洁和语义化;而目录是给开发者看的,我们希望它按工程模块归拢。当这二者发生冲突时,传统 App Router 会迫使你二选一:要么牺牲 URL,迁就目录结构;要么牺牲代码组织,在全局布局里写一堆条件判断。
路由组就是为这个矛盾而生的。它的核心价值,是把“目录怎么组织”和“URL 长什么样”彻底解耦。
1.2 括号目录:让目录和 URL 解耦
实现方式简单得有点反直觉:只要把目录名用一对半角括号包起来,这个目录就成了路由组。路由组目录不会被转化为 URL 的任何一段,它就像一个“透明容器”,只负责在工程上兜住里面的文件。
把刚才的例子改造一下:
app/ ├─ layout.js ├─ page.js └─ (marketing)/ ├─ layout.js ├─ about/ │ └─ page.js ├─ blog/ │ ├─ page.js │ └─ [slug]/ │ └─ page.js └─ contact/ └─ page.js关键点来了:about、blog、contact的实际 URL 依然是/about、/blog、/blog/xxx、/contact,一个字符都不会多。但目录结构上,它们已经统一收进了(marketing)这个板块里,并且(marketing)/layout.js会只作用于组内的这四个页面,首页page.js完全不受影响。
理解路由组时,最有用的一个心法是:它和普通父目录的唯一区别,就是“不占 URL 位”。其他所有约定照旧——组内可以放layout.js、loading.js、error.js、not-found.js,组内可以继续嵌套子目录,组之间互不可见、互不干扰。
我拿仓库货架打个比方:URL 是商品的前台货号,用户凭货号去找商品;文件目录是仓库货架,开发者按习惯去归类箱子。路由组相当于允许你随意调整货架分区,但绝不改动货号本身。以前调整货架必然动货号,现在不用了,仓库管理员的自由度一下子高了很多。
2. 一个实际项目的路由组布局落地
2.1 三段式板块:前台、用户中心、管理后台
讲完机制,直接上一个我现在会在真实项目里用的目录结构。假设需要做一个站点,包含三个明显差异的板块:
- 对外营销面:首页、关于、博客、联系。这套页面带品牌导航和全站底部信息,风格自由,需要 SEO 元信息。
- 用户中心:仪表盘、订单列表、个人设置。页面必须校验登录态,布局是一个左侧边栏加右侧内容区。
- 管理后台:只要一个极简暗色布局,访问者必须拥有管理员权限。
没有路由组的时候,这三个板块的布局需求根本没法在一个根layout.js里优雅解决:把营销导航放进根布局,用户中心和管理后台会跟着套上;把登录校验写进每个页面组件,又会散落一地重复逻辑。用路由组,这个项目长这样:
app/ ├─ layout.js // 根布局:html/body + 全局 Provider ├─ globals.css │ ├─ (site)/ │ ├─ layout.js // 品牌导航 + 页脚 │ ├─ page.js // 路径:/ │ ├─ about/page.js // 路径:/about │ ├─ blog/page.js // 路径:/blog │ ├─ blog/[slug]/page.js // 路径:/blog/hello-world │ └─ contact/page.js // 路径:/contact │ ├─ (account)/ │ ├─ layout.js // 侧边栏 + 顶栏用户信息 │ ├─ dashboard/page.js // 路径:/dashboard │ ├─ orders/page.js // 路径:/orders │ └─ settings/page.js // 路径:/settings │ └─ admin/ ├─ layout.js // 极简后台壳 + 管理员校验 ├─ page.js // 路径:/admin └─ analytics/page.js // 路径:/admin/analytics注意这里的(site)和(account)仅仅是代码层的“业务分区”,线上 URL 里没有site、account字样。/orders还是/orders,/settings还是/settings。而admin我没有加括号,因为它的路径本来就叫/admin,没必要多套一层,结构上更直白。
布局的写法上,和普通layout.js没有任何区别:
// app/(site)/layout.js export default function SiteLayout({ children }) { return ( <div className="site-shell"> <SiteHeader /> <main>{children}</main> <SiteFooter /> </div> ) }// app/(account)/layout.js import { requireUser } from '@/lib/auth' export default async function AccountLayout({ children }) { const user = await requireUser() return ( <div className="account-shell"> <Sidebar user={user} /> <section>{children}</section> </div> ) }服务端组件布局里直接做requireUser(),登录态不达标就redirect('/login')。这段逻辑写在(account)/layout.js里之后,dashboard、orders、settings三个页面自动获得鉴权保护,不需要在页面里重复调用。这就是路由组带来的第二个核心收益:布局和鉴权这类“横向逻辑”可以精确地套在指定板块上,而不是全局一处、到处判断。
2.2 文件路径、URL 与布局的映射关系
上面这个结构,看起来简单,但新手第一次看很容易懵:为什么文件路径里明明有(account),浏览器里却没有?为了彻底扫清这个疑惑,我把对应关系列成一张表,你对着项目结构看一遍就会非常有数。
| 文件路径 | 最终 URL | 生效布局 |
|---|---|---|
app/layout.js | 全部路由 | 根布局(html/body/全局Provider) |
app/(site)/layout.js | /、/about、/blog、/contact | 品牌导航 + 页脚 |
app/(site)/page.js | / | 根布局 +(site)布局 |
app/(site)/blog/page.js | /blog | 根布局 +(site)布局 |
app/(site)/blog/[slug]/page.js | /blog/任意值 | 根布局 +(site)布局 |
app/(account)/layout.js | /dashboard、/orders、/settings | 侧边栏 + 鉴权 |
app/(account)/orders/page.js | /orders | 根布局 +(account)布局 |
app/admin/layout.js | /admin、/admin/analytics | 根布局 + 管理后台布局 |
app/admin/analytics/page.js | /admin/analytics | 根布局 + 管理后台布局 |
这张表理清之后,有两个细节值得单独强调。
第一,(site)/layout.js不会作用于(account)下的任何页面,反之亦然。路由组天然隔离布局作用范围,这是它比“在根布局里用pathname判断隐藏导航”要干净得多的原因。你可以把路由组理解成“会议室隔断”:同一个楼层(App Router)里,几个会议室(路由组)的装修互不影响。
第二,根布局app/layout.js依然是所有页面的底壳,包含<html>、<body>和全局样式变量。(site)和(account)里的布局只是“子布局”,嵌套在根布局之下。这是最常见的单根布局用法,安全、直观、好维护。
2.3 局部 loading 和错误边界的额外收益
布局只能精确套板块,路由组还带来一个容易忽略的福利:loading.js、error.js、not-found.js这些约定文件同样可以放在路由组里,把加载态和错误边界也做成分区版本。
举个例子,用户中心的数据请求通常比较重,仪表盘要拉统计数据,订单页要拉订单列表。在(account)/loading.js里放一个带骨架屏的组件,那么dashboard、orders、settings三个页面导航切换时,就会统一显示这套骨架屏。代码是这样:
// app/(account)/loading.js export default function AccountLoading() { return ( <div className="account-skeleton"> <aside className="sidebar-skeleton" /> <main className="content-skeleton" /> </div> ) }同时,营销站的(site)/loading.js可以放另一套更轻的页面级加载动画。两套加载态互不覆盖,各管各的板块。
同理,(account)/error.js只处理用户中心的异常,用户不会在浏览博客时看到一个写着“订单加载失败”的错误页。板块自身的not-found.js也能在局部定制 404 体验,比如用户中心内部的独立 404 页面,而不是每次都回落到全局 404。能不能用、怎么用,核心原则只有一个:任何布局级的约定文件,放进路由组后作用域自然收窄,行为符合直觉。
3. 进阶用法:多根布局、代码隔离与全局工具配合
3.1 多个根布局:什么时候拆,拆了有什么代价
前面提到的用法,app/layout.js始终保底。Next.js 还允许一种更激进的玩法:删除app/layout.js,让每个路由组各写各的根布局,每个根布局里都有独立的<html>和<body>。
什么场景会想这么做?当两个板块在“HTML 外壳”层面就完全不同时。比如营销官网需要<html lang="zh-CN">加完整的 SEO meta,运营后台需要极简结构、禁止搜索引擎收录,甚至需要完全不同的字体和基础背景。把这些差异放在同一个根布局里用条件判断,会非常蹩脚,因为<html>只能有一个,主题字体、全局类名都难以分叉。
拆法很简单:把app/layout.js删掉,改成这样:
app/ ├─ (site)/ │ ├─ layout.js // 包含 <html> <body>,服务于营销面 │ └─ page.js // / ├─ (admin)/ │ ├─ layout.js // 另一个 <html> <body>,服务于管理后台 │ └─ admin/ │ └─ page.js // /admin但这一步的代价非常大。Next.js 的规则是:如果存在多个根布局,那么每个 URL 都必须能匹配到至少一个带根布局的路由组;如果某个 URL 落不进任何一个组,或者某个组里没有根布局,访问时就会直接报错,常见现象是运行时的 400 Bad Request。也就是说,多根布局要求你把全站所有页面都严格划分到各个组里,“组外裸奔”的目录一个都不能有。
我的实测感受是:多根布局在 90% 的项目里都不该碰。绝大多数场景下,一个根布局负责<html>、<body>、语言、全局样式已经足够,剩下的差异交给子布局去处理。如果你发现自己在思考多根布局,先问一句:差异真的到了需要两套独立<html>的级别吗?如果只是导航、侧边栏、风格配色的区别,路由组加子布局完全能搞定,没必要把全站路由的安全边界搞得那么紧绷。
3.2 按板块收拢代码:组件、样式、hooks 不进 URL
路由组还有一个我没料到会这么好用的玩法:把和某个板块强相关的组件、样式、hook 直接放进路由组目录里,而不是扔进全局components目录。它们不会被当成页面路由,因为只有page.js、route.js、layout.js这类约定文件才会参与路由匹配。
拿营销站举例:
app/ └─ (site)/ ├─ components/ │ ├─ SiteHeader.js │ └─ NewsletterForm.js ├─ hooks/ │ └─ useTrackPageView.js ├─ styles/ │ └─ site.css ├─ layout.js └─ about/ └─ page.jscomponents、hooks、styles这三个文件夹虽然物理上待在app目录下,但因为里面没有page.js,它们永远不会产生路由。这带来的实际收益很直接:
- 营销板块的组件跟着营销板块走,删除整个
(site)目录时,相关代码一次清空,不会在全局components里留下僵尸组件。 - 写代码时不必跨目录跳跃,板块负责人维护自己的目录,合并冲突的频率明显下降。
- 组件对“板块归属”有天然标识。
(site)/components/SiteHeader.js一看就知道是前台的,(account)/components/Sidebar.js一看就是用户中心的。
和路由组搭配的另一个技巧是私有文件夹,也就是目录名前加下划线:_components。私有文件夹明确告诉 Next.js “这不是路由”,也比普通目录更清晰地向同事传递“内部实现”的语义。我惯用的组合是:
app/ └─ (site)/ ├─ _components/Header.js ├─ _lib/analytics.js ├─ layout.js └─ page.js用路由组做业务分区,用_前缀做实现细节分区。这样连开发环境下的路径提示都是规整的,不会让任何人误以为(site)/_components是一个可访问的 URL。
这里要提一个非常现实的坑:在路由组里放普通组件文件夹时,不小心塞进了一个page.js,它会立刻变成一个真实路由。比如你在(site)/_components/Button/page.js这种路径下放了个试错文件,线上就会多出一个奇怪的 URL。所以放进app目录的任何page.js都要抱着“这是对外接口”的心态去创建,绝不能随手乱放。
3.3 和动态路由、中间件一起用要留神
路由组不是只能放在目录顶层。它和动态段、中间件组合时,能支撑更复杂的项目形态,但也容易踩坑。
最常见的是国际化项目。假设整个站点都支持多语言,URL 结构是/en/about、/zh/about这种语言前缀,同时每个语言下又有营销面和用户中心两套布局:
app/ ├─ [locale]/ │ ├─ (site)/ │ │ ├─ layout.js │ │ ├─ page.js // /en 或 /zh │ │ └─ about/page.js // /en/about 或 /zh/about │ └─ (account)/ │ ├─ layout.js │ ├─ dashboard/page.js // /en/dashboard 或 /zh/dashboard │ └─ settings/page.js // /en/settings 或 /zh/settings └─ ...这里的(site)和(account)安安静静地待在[locale]下面,URL 里只有语言和业务路径,没有任何括号噪声。这就是路由组在动态段里最常见的用途:让局部布局在动态前缀之下仍然能精确分组。
和中间件搭配时,有一个隐蔽 bug 特别值得提醒。有些开发者会把路由组名当作pathname的一部分去匹配:
// ❌ 错误示范:pathname 里永远不会有 (account) export function middleware(request) { const pathname = request.nextUrl.pathname if (pathname.startsWith('/(account)')) { // 逻辑永远不会执行 } }request.nextUrl.pathname拿到的是真实 URL 路径,比如/dashboard,而不是代码目录/account。路由组名只存在于文件系统层面,在中间件、服务端解析、客户端路由里统统不可见。正确做法是用真实路径去匹配:
// ✅ 正确示范 if (pathname.startsWith('/dashboard') || pathname.startsWith('/orders')) { // 做点什么 }这个错很坑的地方在于不会立刻报错,只是某段逻辑永远不触发,等发现问题时往往已经上线一段时间了。排查思路也很简单:拿浏览器地址栏的 URL 作为唯一真源,别拿目录结构去套。
同理,生成sitemap.xml、读取params、配置 redirects 时,看到的都是真实路径。凡是涉及 URL 字符串处理的地方,路由组都是透明的,不能也不该出现在匹配规则里。
4. 实战避坑:路由组会踩的几个典型坑
4.1 两个组映射到同一个 URL 会直接报错
这是路由组最容易触发的硬冲突。举个例子:
app/ ├─ (marketing)/ │ └─ about/page.js // 对应 /about └─ (blog)/ └─ about/page.js // 也对应 /about两个路由组里都有about页面,最终 URL 都是/about,Next.js 在构建或运行时直接给你一个明确的冲突报错,意思就是“存在多个路由解析到同一个 URL”。这不是 warning,是 error,不修掉项目根本跑不起来。
这个坑的本质是:路由组只解决了目录归类的需求,但 URL 的唯一性仍然不可破坏。我在实际项目里遇到的错误版本不是这么直白的两个about,而是一个组里放了about/page.js,另一个组里放了[slug]/page.js,然后某个动态值恰好落成了about。URL 表面上不重复,但泛匹配时产生了歧义。所以任何时候用路由组重排目录后,都要在浏览器里把关键路由逐个点一遍,确认没有路由被“吃掉”。
4.2 删掉 app/layout.js 之后 404/400
多根布局那节提过,删掉全局app/layout.js之后,如果有些页面落进了没有根布局的路由组,或者干脆在组外裸奔,访问时就会得到难以理解的异常。我在本地测试时就遇到过一次:删掉app/layout.js,新建了(site)/layout.js和(admin)/layout.js,但页面文件手滑放到了app/root/page.js,既没被(site)接住也没被(admin)接住,浏览器刷新直接 400。
排查方法倒也不复杂:
- 确认
app目录下是否还存在layout.js。存在就是单根布局模式,不存在就是多根布局模式。 - 多根布局模式下,把
app下所有页面文件过一遍,看它们的目录归属是否都落在某个带根布局的路由组内。 - 遇到 400/404,先恢复
app/layout.js作为临时止血,再逐步调整目录归属。
一个稳妥的判断标准:不要让任何page.js直接裸露在路由组之外。如果你真的需要多根布局,最好把全部页面文件都按板块收进组内,不留孤儿目录。
4.3 中间件 matcher 写错目录名的隐蔽 bug
上面中间件那段已经提过匹配问题,我再补一个真实发生的场景。某次我在middleware.ts里想做统计埋点,只统计用户中心流量,随手写了:
export const config = { matcher: ['/((?!_next|api).*)'], }然后在函数里判断:
if (pathname.startsWith('/account')) { ... }结果统计一直为零。一开始以为路由组影响到了pathname,还特地去打印日志。折腾半天才发现,目录是(account),真实 URL 是/dashboard、/orders,我拿代码目录名去匹配真实路径,自然什么都匹配不到。
这个错误非常容易犯,因为你在编辑器里天天看着(account)这个目录,潜意识会觉得它就在 URL 里。但它确实不在。路由组的一个隐性“代价”就是:代码里看到的路径和浏览器里的路径不完全一致,增加了心智负担。这也是团队协作时需要写注释或 README 的原因。
4.4 改目录结构后命中 dev server 缓存
路由组本质上是改了文件系统的目录层级。某些情况下,尤其是把已有页面从一个普通目录挪进路由组、或者反向挪出时,dev server 还保留着旧的编译缓存,页面会继续按旧路由渲染,甚至出现新 URL 404、旧 URL 依然可访问的情况。
这个坑遇到过一次之后,我养成了习惯:凡是涉及目录平移、路由组改名这类结构性操作,改完直接重启 dev server,别在热更新里赌缓存。在部署环境里,重新构建也一定是最稳妥的选择。它不是路由组本身的 bug,而是工程中很容易被忽略的一环,写出来给各位提个醒。
5. 我的使用原则:什么场景收益最大
5.1 用路由组的三条判断标准
用了很久路由组之后,我给自己总结了一套判断标准,拿不准该不该分一个路由组时,就问三个问题:
- 这一组页面是否共享一个独有的布局?如果整组页面都需要同一套导航、同一套鉴权、同一套加载态,而组外页面不需要,就值得拆。
- 这个分组是否在业务上有明确归属?比如“营销面”“用户中心”“管理后台”,业务边界清晰才值得拆。如果只是技术意义上想少写几行代码,拆完会让别人看不懂。
- 分组是否会影响 URL 语义?如果拆完之后 URL 变丑、变长、变重复,就不要拆。路由组可以帮你避免这种影响,但前提是你清楚 URL 的期望值。
三条都满足,拆。满足一条或两条,先不拆,平铺反而更好。
5.2 我建议你在项目里立刻用起来的最小改动
如果你还没在项目里用过路由组,我建议找一个现成的、已经明显有板块划分的项目,做一次最小改动来体验:把共享同一套layout的 3 到 5 个页面,挪进一个(板块名)目录,把原来的布局文件移进去,然后在浏览器里逐个确认 URL 没有变化。不要一上来就拆多根布局,不要一上来就重排全站目录,小步快跑才能感受到它带来的组织结构收益,而不是被一堆迁移问题淹没。
我个人的一个操作体会是:路由组名字一定要按“业务板块”起,而不是按“技术角色”起。(site)、(account)、(admin)、(marketing)都是好名字,因为团队里任何人都能一眼看出这是哪个业务域。反过来,(pages)、(routes)、(utils)这类名字就非常劝退,它们没有传达业务信息,只会让人疑惑“这是个什么分组”。
最后再分享一个团队协作上的小收益:因为路由组把目录切分成了独立板块,多人维护一个项目时,每个人的改动基本落在自己的组内,git合并冲突的概率明显下降。开发、设计、产品之间的沟通也会更顺畅——讨论“用户中心”时,代码结构里确实就有一个(account)文件夹,而不是散落在各种语义不明的路径里。
URL 是给用户和搜索引擎看的,目录是给开发团队看的,路由组就是这两者之间的解耦层。它能解决的,本质上是“产品结构”和“代码结构”不完全一致时的组织问题。这种问题在项目小的时候不存在,在项目大了之后一定会出现。等它出现时,第一个想到的解决方案,就应该是路由组。