Next.js 静态导出(output: export)踩坑记录

ShuaiQi
#前端#Next.js#部署

把 Next.js 项目做成纯静态站点(输出 HTML + 资源文件,丢到任何静态服务器都能跑)是很常见的需求,但 output: 'export' 会关掉一批服务端能力,踩坑点集中在配置和构建。

基础配置

ts
// next.config.ts
const nextConfig = {
  output: process.env.NODE_ENV === "production" ? "export" : undefined,
  trailingSlash: true,
  images: { unoptimized: true },
};
  • output: 'export' 只在生产构建时开启,开发时保留完整能力
  • trailingSlash: true 让每个页面输出成 目录/index.html,这样 Nginx、Apache、GitHub Pages 都不需要额外配置就能访问
  • images.unoptimized: true 是必须的,静态导出没有图片优化服务

坑一:rewrites / redirects / headers 完全不生效

构建时会看到警告:

text
⚠ Specified "rewrites" will not automatically work with "output: export"

这不是提示,是事实——静态导出没有服务端,这些配置一条都不会执行。需要跳转或重写,只能:

  • 用服务器配置(Nginx try_files、Apache .htaccess)
  • 用 Cloudflare Rules / Pages Functions
  • 在客户端用 JS 兜底(不推荐用于 SEO 相关跳转)

本站的 www → 非 www 跳转就是三管齐下:Cloudflare 规则 + .htaccess 的 301 + 页面内联脚本兜底。

坑二:动态路由必须 generateStaticParams

ts
export function generateStaticParams() {
  return getAllSlugs().map((slug) => ({ slug }));
}

少写这一步,构建会直接报错。dynamicParams: true 在静态导出下也不可用——所有页面必须在构建时确定。

坑三:Route Handler 只能是静态 GET

静态导出支持 app/xxx/route.ts,但限制很明确:

  • 只能用 GET
  • 不能依赖 Request 的参数(query、header、cookie)
  • 返回值会在构建时算好,写成一个静态文件

所以可以用它生成 RSS:

ts
export const dynamic = "force-static";

export function GET() {
  const posts = getAllPosts();
  return new Response(buildXml(posts), {
    headers: { "Content-Type": "application/rss+xml; charset=utf-8" },
  });
}

但不能用它做登录、表单提交、数据库读写这类接口。本站的评论用 giscus、下载数据走 Supabase 直连,就是为了绕开这个限制。

坑四:sitemap 的生成时机

用 next-sitemap 时,它默认写 public/,而 public/ 会在构建时被复制到 out/。结果就是这次构建的 sitemap 里永远没有这次新增的页面,要等下一次构建才补上。

解决办法是直接让它写构建产物目录:

js
module.exports = {
  siteUrl: "https://example.com",
  outDir: "out",
};

并且确保 npm 脚本是:

json
{
  "scripts": {
    "build": "next build && next-sitemap"
  }
}

如果只跑 npx next build,out/ 里根本不会有 sitemap.xml,而 robots.txt 还指着它——搜索引擎抓到的就是 404。

坑五:所有页面的 lastmod 都一样

next-sitemap 的 autoLastmod 默认是 true,会用构建时间覆盖你在 transform 里返回的值。要按文章真实日期输出,必须显式关闭:

js
module.exports = {
  autoLastmod: false,
  transform: async (config, path) => ({
    loc: path,
    lastmod: getPostDate(path) || new Date().toISOString(),
    changefreq: "monthly",
    priority: 0.8,
  }),
};

另外 transform 收到的 path 不带结尾斜杠,用它做 map 查询时要把两种形式都登记,否则永远查不到。

还有一点:不要在 transform 里 return { ...config, ... },那会把 autoLastmod、exclude 这些内部字段序列化进 sitemap。

坑六:404 页面

静态导出会生成 404.html,但服务器需要配置才知道怎么用它:

nginx
error_page 404 /404.html;

Apache 用 .htaccess:

apache
ErrorDocument 404 /404.html

不做这一步,访问不存在的路径会看到服务器默认的 404,体验和品牌都不对。

坑七:客户端组件不能导出 metadata

一个页面如果顶部写了 "use client",就不能同时 export const metadata。常见错误是「页面需要交互,于是整个文件加了 use client」,结果 SEO 信息全没了。

正确做法是把交互部分拆成子组件,页面本身保持服务端组件:

tsx
// app/downloads/page.tsx(服务端)
import DownloadList from "@/components/downloads/DownloadList";

export const metadata = { title: "下载" };

export default function Page() {
  return <DownloadList />;
}

检查清单

  1. out/ 里是否有 sitemap.xml、feed.xml、404.html
  2. 所有页面链接是否以 / 结尾(配合 trailingSlash)
  3. robots.txt 里的 sitemap 地址是否可访问
  4. 图片路径是否都是绝对路径(/images/x.jpg)
  5. 是否有依赖 rewrites 的路由,部署端是否补了等价配置

常见问题

静态导出能做搜索功能吗?

可以,但要么在构建时生成索引文件由前端搜索,要么接第三方搜索服务(如 Algolia)。

部署到 Cloudflare Pages 还是自己服务器?

两者都行。Pages 免费、自带 CDN 和 HTTPS;自己的服务器可控性更强,配合 Cloudflare 也能获得同样效果。

为什么本地 next start 正常,上传后就 404?

next start 跑的是 Node 服务,而静态服务器只认文件。检查是否真的上传了 out/ 目录,以及服务器是否正确处理目录默认页。

能不能只在生产环境导出?

可以,用 process.env.NODE_ENV 判断即可,开发时保留完整能力更方便调试。

相关阅读

评论