return null:Next.js App Router 博客的 14 个 SEO 死穴
Googlebot 爬你的博客,看到的是一片空白。不是服务器挂了,不是页面 404,是你亲手写的 return null 把整个 <body> 清空了。
0. 症状
部署了一个 Next.js 16 + App Router 的技术博客,文章全是 Server Component,metadata 配得整整齐齐,sitemap 也有,robots.txt 也放了。但 Google Search Console 里,收录数是 0。
curl 一看 HTML 源码:
xml
<body>
<!-- 空的 -->
</body>
6 篇精心写的深度技术文章,Googlebot 一个字都没看到。
1. 元凶:ClientOnly 的 return null
根 layout 里有一个 ClientOnly 组件包裹了整个 {children}:
javascript
// components/AuthProvider.tsx
'use client'
export function ClientOnly({ children }) {
const [mounted, setMounted] = useState(false)
useEffect(() => { setMounted(true) }, [])
if (!mounted) return null // ← SSR 阶段永远走这里
return <AuthProvider>{children}</AuthProvider>
}
xml
// app/layout.tsx
<body>
<ClientOnly>{children}</ClientOnly>
</body>
SSR 阶段 mounted = false → return null → HTML body 为空。
这个组件的原意是等客户端 hydration 完成后再渲染,避免 auth 状态闪烁。但副作用是:所有页面的 SSR 输出为零。Googlebot 虽然能执行 JS,但需要等 hydration 完成才能看到内容,爬取效率和索引优先级大幅下降。
修复 :删掉 if (!mounted) return null,让 SSR 阶段也正常输出 children。
javascript
export function ClientOnly({ children }) {
const [mounted, setMounted] = useState(false)
useEffect(() => { setMounted(true) }, [])
// 不阻塞 SSR:mounted=false 时也输出 children
return <AuthProvider>{children}</AuthProvider>
}
Auth 状态在 SSR 阶段是空的,没关系------博客文章不需要登录态。
2. cookies() 暗杀 ISR
修完 SSR 后,给博客列表页配了 ISR:
arduino
export const revalidate = 3600 // 每小时重新生成
但发现每次请求仍然走服务端渲染,ISR 缓存完全没生效。
原因:页面里调用了 cookies()。
javascript
// blog/page.tsx
import { cookies } from 'next/headers'
export default async function BlogPage() {
const cookieStore = await cookies() // ← 这行杀死了 ISR
const token = cookieStore.get('token')?.value
// ...
}
在 Next.js App Router 中,cookies() 是动态函数(Dynamic Function)。一旦调用,无论你怎么设 revalidate,页面都会强制进入动态渲染模式。ISR 形同虚设。
修复:把 cookie 逻辑移到客户端组件里。博客列表页本来就不需要在服务端读 cookie。
3. 缺 metadataBase,canonical 全废
每篇文章都配了 openGraph.url,但没在根 layout 设 metadataBase:
arduino
// ❌ 之前
export const metadata: Metadata = {
title: "DiffServ --- V8 Performance Lab",
}
// ✅ 之后
export const metadata: Metadata = {
metadataBase: new URL("https://diffserv.xyz"),
title: "DiffServ --- V8 Performance Lab",
}
没有 metadataBase,所有相对路径的 canonical URL、OG 图片地址都无法被 Next.js 解析为绝对 URL。搜索引擎拿到的是残缺的 meta 信息。
4. www 和裸域同时响应,权重分裂
Nginx 配置:
ini
server_name diffserv.xyz www.diffserv.xyz;
两个域名同时响应相同内容,Google 视为两个独立站点,PageRank 被一分为二。
修复:www 单独做 301:
perl
server {
listen 443 ssl http2;
server_name www.diffserv.xyz;
return 301 https://diffserv.xyz$request_uri;
}
5. 没有 HSTS,每次首访多一次重定向
有 HTTP→HTTPS 301,但没有 Strict-Transport-Security 头。用户每次输入 diffserv.xyz 都要经历一次 80→443 的重定向,白白多 100-300ms。
ini
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains; preload" always;
6. 静态资源没有长缓存头
Next.js 的 /_next/static/ 文件名自带 content hash,天然可以永久缓存。但 Nginx 没配:
arduino
location /_next/static/ {
proxy_pass http://web;
add_header Cache-Control "public, max-age=31536000, immutable" always;
}
没有这行,浏览器每次都要发条件请求验证缓存,白白浪费 RTT。
7. 没有 RSS
技术博客没有 /feed.xml = 放弃了 Feedly、Inoreader 等 RSS 阅读器的整个流量入口。在 Next.js App Router 里用 Route Handler 生成:
xml
// app/feed.xml/route.ts
export async function GET() {
const items = blogPosts.map(post => `
<item>
<title>${post.title}</title>
<link>https://diffserv.xyz/blog/${post.slug}</link>
<pubDate>${new Date(post.date).toUTCString()}</pubDate>
<description>${post.description}</description>
</item>
`).join('')
const xml = `<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0">
<channel>
<title>DiffServ Lab</title>
<link>https://diffserv.xyz</link>
${items}
</channel>
</rss>`
return new Response(xml, {
headers: { 'Content-Type': 'application/rss+xml' },
})
}
8. 没有 OG 图片
所有文章声明了 twitter.card: summary_large_image 但没给图片 URL。社交平台分享是纯文本链接,点击率比带图低 40%+。
Next.js App Router 支持 app/opengraph-image.tsx 动态生成 OG 图片,或者在 public/ 放一张默认图然后在全局 metadata 里引用。
9. JSON-LD 缺 dateModified 和 image
Google Rich Results 要求 BlogPosting 类型至少包含 headline、datePublished、dateModified、image、author。缺少 dateModified 和 image,搜索结果中不会显示富媒体摘要(发布日期、缩略图)。
10. 没有 404 / 500 页面
Next.js App Router 默认的 404 是一个白底黑字的 "404 | This page could not be found",没有导航、没有推荐内容。用户点到死链直接流失。
创建 app/not-found.tsx 和 app/error.tsx,至少给一个回首页的链接和几篇推荐文章。
11. next.config.ts 为空
ini
const nextConfig: NextConfig = {};
至少加两行:
yaml
const nextConfig: NextConfig = {
poweredByHeader: false, // 隐藏 X-Powered-By: Next.js
images: { formats: ['image/avif', 'image/webp'] },
};
poweredByHeader 暴露技术栈给攻击者;不启用 AVIF 意味着放弃了 30-50% 的图片压缩率。
12. viewport 禁止缩放
yaml
export const viewport: Viewport = {
maximumScale: 1,
userScalable: false,
}
WCAG 2.1 明确要求用户能放大到至少 200%。这两行让 Lighthouse Accessibility 直接扣分。删掉。
13. sitemap lastModified 每次构建都变
javascript
lastModified: new Date(), // ← 每次 ISR 重生成都是新时间
Google 看到所有 URL 的 lastModified 同时变化,会重新爬取全站,浪费 crawl budget。硬编码真实的修改日期。
14. 内部链接用了 <a> 而不是 <Link>
部分博客文章里的内部跳转(/lab、/blog/xxx)用了原生 <a> 标签。Next.js 的 <Link> 组件会自动 prefetch 目标页面,用 <a> 则触发全页刷新,白白丢掉了客户端路由的性能优势。
对标 Astro:Next.js 的额外成本
| 维度 | Astro 默认 | Next.js 需要手动做 |
|---|---|---|
| SSR 输出 | 纯 HTML,零 JS | 需确保不被 ClientOnly 阻断 |
| ISR | 默认 SSG | 需手动配 revalidate,且不能碰 cookies() |
| RSS | @astrojs/rss 一行配 |
手写 Route Handler |
| OG 图片 | 社区包成熟 | opengraph-image.tsx 或手动 |
| 零 JS | 默认不发送 runtime | Server Component 不 hydrate,但仍有 React runtime 开销 |
| sitemap | @astrojs/sitemap 自动 |
手动实现,需注意 lastModified |
Astro 的优势是默认值就是最佳实践 。Next.js 的优势是灵活性------但灵活性的代价是你必须知道每个默认值背后的坑。
如果你的博客是纯内容站,Astro 确实省心。但如果你的站点同时有博客、交互式 Lab、用户系统、API------Next.js 的全栈能力是 Astro 替代不了的。关键是:把该配的配好,把该删的删掉。
修完之后
14 项全部修完后的状态:
- HTML 源码可见全部文章内容,Googlebot 无需执行 JS
- ISR 缓存生效,TTFB 从 ~500ms 降到 ~50ms
- 社交分享带品牌 OG 图片
- RSS 接入全球阅读器生态
- HSTS preload + www 301 + immutable 缓存
- Lighthouse Performance / SEO / Accessibility / Best Practices 全绿
不需要换框架。Next.js 能做到 Astro 做的一切,前提是你知道哪些地方需要手动补。
GitHub: hlng2002/stw-sentinel 在线实验: diffserv.xyz/lab