go-web-utils
Next.js 静态托管 (nextstatic)

Next.js 静态托管 (nextstatic)

Next.js 15/16 export 产物托管,RSC 头处理、动态路由回退、SEO 注入

nextstatic

Next.js 静态导出(output: 'export')产物的 Go 托管处理器,兼容 Next.js 15 与 16 两代导出结构。由 Go 统一提供 /api 与静态页面时需要正确处理一组容易踩坑的细节,本包收敛为一个开箱即用的 http.Handler

解决的问题

处理
RSC payload .txt 走默认 text/plain设为 text/x-component; charset=utf-8,否则 Next.js 16 客户端路由回退硬导航,页面出现一片 RSC 文本;Next 15 的每目录 index.txt 同样处理
CDN 把 HTML 与 RSC 缓存串台RSC 响应带 Vary: RSC, Next-Router-State-Tree, Next-Router-Prefetch
robots.txt 被误判为 RSC按文件名特征区分,普通 .txt 保持 text/plain
Next 16 route group 点号路径 404/admin/users/__next.!<b64>.admin.users.__PAGE__.txt 自动还原为磁盘目录结构查找
CDN 缓存旧 HTML 引用已覆盖的 chunkHTML 用硬化 no-store, no-cache, must-revalidate, max-age=0/_next/static 长期缓存 + immutable
trailingSlash: true 刷新 404无扩展名页面请求先 308 补斜杠(Location 强制相对路径,防开放重定向)
动态路由直达 404/voddetail/123/ 回退占位符目录 /voddetail/_/index.html;RSC 数据文件同路回退;另支持"动态详情导出在父级"形态(/app/123//app/index.html)的逐级父目录回退
未命中兜底根首页造成软 404 + RSC fetch 死循环默认返回 404.html + 404 状态码;纯 SPA 项目显式开 SPAFallback 恢复"根 index + 200"
未注册 API 路径被页面兜底吞掉命中 APIPrefixes(默认 /api/)直接返回 JSON 404
index.html 被浏览器当 Service Worker 注册/sw.js 强制 no-cache + Service-Worker-Allowed: /,缺失时硬 404 绝不回退 HTML
动态 SEOSEOInject 钩子在 HTML 写出前注入 __SEO_*__ 占位符内容

使用

h, err := nextstatic.New(nextstatic.Config{
    Root:          "./web/out",
    TrailingSlash: true, // 与 next.config 对齐
    SEOInject: func(r *http.Request, html []byte) []byte {
        return seo.Render(r.URL.Path, html) // 注入内容须自行 HTML 转义
    },
    // APIPrefixes: 默认 ["/api/"]; 传空 slice 关闭
    // SPAFallback: 纯 SPA 项目 (无 SSG 动态页) 设 true
})
if err != nil {
    log.Fatal(err)
}

mux := http.NewServeMux()
mux.Handle("/api/", apiRouter)
mux.Handle("/", h) // 兜底路由

Gin 集成

返回值是标准 http.Handler,Gin 用 gin.WrapH 挂到 NoRoute:

r := gin.Default()
r.GET("/api/users", listUsers) // API 路由正常注册, 优先匹配
r.GET("/sitemap.xml", genSitemap) // 动态 sitemap 等特殊路由在外层先挂
r.NoRoute(gin.WrapH(h)) // 其余请求全部交给静态托管

配合细节:

  • 已注册的 /api 路由不进 NoRoute;未注册/api/* 落进 NoRoute 后由 APIPrefixes 返回 JSON 404,不会被页面兜底吞掉,无需再手写这段防护
  • Gin 的 RedirectTrailingSlash 只对已注册路由生效,NoRoute 请求的补斜杠由本包的 308 处理,两者不冲突
  • r.Use(...) 挂载的日志、限流、机器人拦截等中间件照常在 NoRoute 之前执行

解析顺序

API 前缀 JSON 404 → 直接静态文件 → RSC 点号路径还原 → RSC 占位符/父目录回退 → trailing slash 308 → 目录 index.html<path>.html → 动态路由占位符 → 逐级父目录 index.html404.html(或 SPA 兜底)

sitemap、旧链接重定向等特殊路由应在外层路由先行匹配,再落到本 Handler。

限制

  • 动态段占位符回退只处理单级动态段(最后一级目录替换为 _);多级嵌套动态段在外层路由先行改写
  • HTML 模板每次请求读盘;高 QPS 的 SEO 注入场景建议在 SEOInject 上层自行缓存模板

独立工具函数

nextstatic.IsRSCPayload("/about/__next._index.txt") // true
nextstatic.IsRSCPayload("/robots.txt")              // false

nextstatic.SetHeaders(w, urlPath) // 按路径分类设 Content-Type/Vary/缓存头