独立开发中·📚18 篇日记·📝最近更新:2026年07月12日
🔍

从SPA到SSG:我的SEO迁移血泪史

博客上线一个月,百度收录0。从React SPA迁移到Next.js SSG的全过程。

水哥
2026-07-1210 分钟阅读

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.jsReact 生态兼容,组件可复用学习曲线,很多新概念
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 方案。 别等到写了十几篇文章再迁移——那时候的心态可不是"学到了",而是"白做了"。

相关文章