SPA 的 SEO 陷阱
做了这么多年开发,我一直以为把网站做漂亮就够了。直到上周,我终于按捺不住查了一下百度搜索控制台的后台数据。
收录0,展现0,点击0。
我的博客上线快一个月了,写了好几篇干货文章,Google 搜不到就算了,百度也完全搜不到。我对着屏幕沉默了五分钟。
原因分析:CSR 的先天缺陷
问题出在架构上。我的博客是用 React + Vite 搭的纯 SPA(单页应用):
用户请求 /post/ai-assisted-development
→ 服务器返回空的 index.html
→ 浏览器下载 JS bundle (~200KB)
→ React 执行渲染
→ 内容终于出现了!
搜索引擎爬虫不是浏览器。它拿到一个空壳 HTML,里面只有一个 <div id="root"></div>,然后爬虫就走了。我的八篇文章在爬虫眼里根本就不存在。
<!-- 爬虫看到的 -->
<!DOCTYPE html>
<html>
<head><title>水哥开发日记</title></head>
<body>
<div id="root"></div> <!-- 空的! -->
</body>
</html>
这不是小问题,这是致命伤。我做博客就是为了获取搜索引擎流量,结果搜索引擎根本不知道我写了什么。
痛苦的选型过程
知道问题后,我必须迁移架构。摆在我面前三个选择:
| 方案 | 优点 | 缺点 |
|---|---|---|
| Next.js | React 生态兼容,组件可复用 | 学习曲线,很多新概念 |
| Astro | 内容优先,极致性能 | 几乎要重写所有组件 |
| Hexo | 中文社区,快速上手 | 模板系统完全不同 |
我纠结了一整天。Astro 最适合纯博客,但我现有的 React 组件就全废了。Hexo 太受限,后面想加交互功能会很痛苦。
最终还是选了 Next.js。虽然学习过程很痛苦,但至少能用回现有的数据模型和 UI 组件。
三个让我崩溃的坑
坑 1:fs 模块在客户端组件中爆炸
迁移到 Next.js 后,我的 Markdown 文章是用 fs.readFileSync 读取的。构建时报错:
Module not found: Can't resolve 'fs'
我当时完全懵了。Node.js 原生模块为什么在 Next.js 里找不到?折腾了快两个小时,才搞明白 Next.js App Router 的约定:
默认所有组件都是服务端组件,除非你加了
'use client'。但你一旦加了,fs 就不能用了。
解决方案:数据层分离。
// ❌ 错误:在客户端组件中用 fs
'use client'
import fs from 'fs'
// ✅ 正确:数据层分离
// src/lib/posts.ts (纯服务端代码)
import fs from 'fs'
import 'server-only' // 关键!编译器会阻止客户端导入
// src/data/posts.ts (纯数据,客户端可用)
export const posts: BlogPost[] = [ /* 元数据 */ ]
server-only 这个包是救命稻草——它会在编译时直接报错,防止你把服务端代码泄漏到客户端。
坑 2:'use client' 的传染性
只要你把一个组件标为 'use client',它导入的所有子组件都会自动变成客户端组件。 我一开始没意识到这一点,把 Navbar 标了 'use client'(因为它有 onClick 事件),结果整个页面树全部降级为 CSR,SSG 白做了。
// ❌ 错误:Navbar 带动整个页面树
'use client'
// 这个文件导入了 Footer, ArticleCard, Badge...
// 全变成客户端组件了!
解决方案:细粒度标记。 只给真正需要交互的组件加 'use client':
// src/components/ArticleCard.tsx
'use client' // 最顶层标记
// 只有这个组件是客户端的,不影响父组件
// src/app/page.tsx (无 'use client' 标记)
// 这是服务端组件,可以正常 SSG
坑 3:generateStaticParams 的路径不匹配
Next.js 的 generateStaticParams 返回的 slug 必须和文件路径 [slug] 完全匹配。我的文章元数据里 slug 都是 'why-i-build-50-platforms' 这样的格式,但我忘了在 page.tsx 里解码 URI 编码。
// ❌ 错误:URL 里编码了连字符不匹配
export function generateStaticParams() {
return posts.map(p => ({ slug: encodeURIComponent(p.slug) }))
// 生成 /post/why-i-build-50-platforms 而不是 /post/why-i-build-50-platforms
}
// ✅ 正确:直接用原始 slug
export function generateStaticParams() {
return posts.map(p => ({ slug: p.slug }))
}
这个 bug 让我调试了快一个小时,构建没有任何报错,只是访问文章页返回 404。一度以为是 Next.js 的 bug,差点去提 issue。
迁移后的收获
1. 所有页面预渲染成功
Route (app)
├ ○ / 110 kB
├ ○ /about 5.2 kB
├ ● /post/[slug] 247 kB
│ ├ /post/ai-assisted-development
│ ├ /post/choosing-tech-stack
│ ├ /post/domain-name-strategy
│ ├ /post/first-100-users
│ ├ /post/microplatform-architecture
│ ├ /post/monetization-strategy
│ ├ /post/solo-developer-routine
│ └ /post/why-i-build-50-platforms
├ ○ /tags 3.8 kB
├ ○ /sitemap.xml (动态生成)
└ ○ /robots.txt (动态生成)
○ (Static) 预渲染为静态 HTML
● (SSG) 自动静态生成 + ISR 就绪
16 个页面,全部在构建时生成了独立的 HTML 文件。每篇文章现在都是一份完整的 HTML 文档,搜索引擎直接能爬。
2. 每篇文章独立的 SEO 元数据
export async function generateMetadata({ params }: Props) {
const post = getPostBySlug(params.slug)
return {
title: `${post.title} - 水哥开发日记`,
description: post.excerpt,
openGraph: {
title: post.title,
description: post.excerpt,
type: 'article',
publishedTime: post.date,
tags: post.tags,
},
twitter: {
card: 'summary_large_image',
title: post.title,
description: post.excerpt,
}
}
}
不再是一套全局 meta 凑合用了。每篇文章有独立的标题、描述和 Open Graph 标签。发给微信、Twitter 和 Slack 的链接卡片,再也不是千篇一律的预览了。
3. 动态 sitemap
// src/app/sitemap.ts
export default function sitemap() {
const baseUrl = 'https://shuige.blog'
return [
{ url: baseUrl, lastModified: new Date() },
...posts.map(p => ({
url: `${baseUrl}/post/${p.slug}`,
lastModified: new Date(p.date),
})),
{ url: `${baseUrl}/tags` },
{ url: `${baseUrl}/about` },
]
}
每次构建自动生成 sitemap.xml,不需要手写,不需要维护。新增文章自动进入 sitemap。
写在最后
这次迁移让我深刻体会到:技术选型不是看热门程度,而是看你的核心目标。
我的核心目标是获取搜索引擎流量,那 SPA 就是错误的选择——不管它有多火。Next.js 虽然学习成本高,但它解决的是我最需要的问题。
迁移总共花了我大半天时间,但这是我这一个月来最值的半天。百度收录需要时间,但至少现在爬虫能看到我的内容了。
如果你也在做个人博客或者内容站,我的建议是:第一天就用 SSG 方案。 别等到写了十几篇文章再迁移——那时候的心态可不是"学到了",而是"白做了"。