Next.js 静态导出子路由全 403,根源是 trailingSlash 没开
把一个 Next.js App Router 项目设成 output: "export" 静态导出,本地 next build 一切正常,rsync 到服务器,首页打开,心情愉快。然后点导航栏里的「博客」——403。点「项目」——403。只有首页是好的。
你去检查 nginx 配置,看起来也没问题。直接 curl https://example.com/blog.html ——200,内容也对。但站内链接都是 /blog,不是 /blog.html,用户怎么可能手敲后缀?
现象
GET /→ 200(正常)GET /blog→ 301 →/blog/→ 403GET /blog/→ 403GET /blog.html→ 200(带后缀反而能开)out/目录里:有blog.html,有blog/目录,但blog/里只有 Next.js 内部的 RSC payload 文件,没有index.html
根因
Next.js output: "export" 的默认导出形态是扁平 html:
out/
index.html ← 首页,nginx 对 / 能命中
blog.html ← /blog 页,但 nginx 不认
blog/ ← 这个目录里没有 index.html!
_next.*.txt ← RSC payload,不是页面
projects/
detail.html
detail/ ← 同上,只有 payload
nginx 处理 GET /blog 的逻辑是:看到同名目录 blog/ 存在,先发 301 重定向到 /blog/,再对目录请求查找 index index.html,目录里没有 → 403(autoindex 默认关闭)。
/blog.html 能开,是因为 nginx try_files $uri 直接命中了文件,不经过目录逻辑。
out/blog/ 目录是 Next.js 放 RSC payload 用的,不是页面目录。但 nginx 看到 blog/ 目录存在,就把 GET /blog 301 到 /blog/,再找 index.html,找不到 → 403。你看 nginx 配置没问题是对的——问题在导出文件结构,不在服务器。
正解
在 next.config 加一行 trailingSlash: true:
// next.config.mjs
const config = {
output: "export",
trailingSlash: true, // 改变导出结构
};
export default config;重新 next build 后,导出结构变成:
out/
index.html
blog/
index.html ← 这才是页面
projects/
detail/
index.html
nginx 对 GET /blog/ 能直接命中 blog/index.html,对 GET /blog 发 301 到 /blog/ 再命中,全程 200。Next.js 的 <Link> href 也会自动规范成带斜杠形式,站内跳转一致。
重新部署时用 rsync --delete 会自动删掉旧的 blog.html,不会留下新旧双份文件。
加了 trailingSlash: true 之后,页面以 路由/index.html 形式导出,这正是 nginx/Apache/任何静态托管平台的通用期望格式。不需要改服务器配置,换任何静态托管都通用。
备选:只改服务器不改导出
如果不想动导出结构(比如多人协作、改 next.config 有别的影响),可以在 nginx location 加 .html 回退:
location / {
root /var/www/out;
index index.html;
try_files $uri $uri.html $uri/ =404;
}try_files 会依次尝试 $uri(精确文件)→ $uri.html(加后缀)→ $uri/(目录),命中 blog.html 就返回,不再走目录逻辑。
trailingSlash: true 是代码侧修复,部署到 Vercel、GitHub Pages、Cloudflare Pages 等任何平台都无需额外配置;try_files 是服务器侧修复,换平台时要记得同步改。优先选前者。
一句话外卖
next export默认产route.html,静态服务器需要的是route/index.html;加trailingSlash: true让导出形态与服务器期望对齐,一行配置消灭所有子路由 403。