这篇文章记录的是一次真实的 Nuxt 博客 SEO 改造。项目使用 Nuxt 4 + Vue 3,文章数据来自独立的博客服务,前端通过 Nuxt 的服务端 API 代理获取数据。

SEO 不是在页面里塞几个关键词就结束了。对博客来说,先要保证搜索引擎能抓到页面,再让它知道每一页是什么,最后才是持续写出值得被搜索到的内容。

一、这次要解决什么问题

改造前已经有基础的 useSeoMeta,但还缺几块影响收录和分享效果的内容:

  • 生产环境的站点地址需要手动配置,不能继续使用 localhost。
  • 所有分页页的 canonical 都指向第一页,容易让搜索引擎把后面的文章列表当成重复页。
  • 搜索结果页没有明确告诉搜索引擎不要收录。
  • 没有前端侧的动态 sitemap,新增文章不能自动进入站点地图。
  • 文章结构化数据比较简单,没有文章关键词、发布时间、修改时间和面包屑。
  • RSS 没有放进页面 head,订阅入口不够明确。

这次的目标很实际:每篇文章都能独立生成标题、摘要、规范链接、分享图片和结构化数据;博客首页、分类页和文章页都能被正常发现;搜索页、API 代理等不适合收录的内容不进入索引。

二、实际改了哪些文件

文件 做的事情
app/composables/useBlogSeo.ts 统一处理 title、description、canonical、robots、Open Graph、Twitter Card、文章 JSON-LD 和面包屑
app/app.vue 增加全站 WebSite 结构化数据和 RSS 链接
app/pages/posts/[id].vue 文章页补充可选关键词、发布时间、修改时间和面包屑
app/pages/category/[slug].vue 优先使用后台配置的分类描述,并补充面包屑
app/pages/articles.vue 分页页使用自己的 title 和 canonical,例如 /articles?page=2
app/pages/search.vue 设置 noindex,follow,避免大量低价值搜索结果 URL 进入索引
server/routes/robots.txt.ts 动态输出 robots.txt,并自动写入 sitemap 地址
server/routes/sitemap.xml.ts 从博客接口读取文章和分类,生成动态站点地图
nuxt.config.ts 增加默认描述,并让 NUXT_PUBLIC_SITE_URL 进入运行时配置
public/robots.txt 删除静态文件,改为服务端动态输出

没有新增 SEO 插件,也没有改页面视觉样式。这样做的好处是改动集中,后续查问题时比较容易定位。

三、第一步:先把生产域名配置好

项目根目录创建 .env

DOTENV
# 博客后端地址
NUXT_API_BASE=http://127.0.0.1:3000

# 必须填写用户真实访问博客的地址,不要写 localhost
NUXT_PUBLIC_SITE_URL=https://你的域名.com

这个变量会被以下内容使用:

  • canonical 规范链接
  • Open Graph 的页面地址
  • JSON-LD 里的 url
  • robots.txt 中的 sitemap 地址
  • sitemap.xml 中的所有 <loc>

如果生产环境忘了配置,网站虽然还能打开,但搜索引擎会看到 http://localhost:3006,这会直接影响 SEO。部署后先访问:

Text
https://你的域名.com/robots.txt
https://你的域名.com/sitemap.xml

检查返回的地址是否已经是正式域名。

四、第二步:统一管理页面 SEO

不要在每个页面里重复写一套 head。项目使用 app/composables/useBlogSeo.ts 统一处理:

TypeScript
useBlogSeo({
  title: '文章标题',
  description: '文章摘要,尽量用一句话说明读者能得到什么。',
  image: articleCover,
  type: 'article',
  publishedTime: articlePublishedAt,
  modifiedTime: articleUpdatedAt,
})

这套函数现在会生成:

  • <title>
  • description
  • robots
  • canonical
  • Open Graph:标题、摘要、类型、地址、站点名、图片和图片 alt
  • Twitter Card
  • zh-CN 页面语言信息
  • WebPageBlogPosting JSON-LD

传入的 title 是页面自己的标题,组合式函数会自动从后台站点设置中读取博客名称并拼到后面。这样首页、文章页和分类页使用的是同一个品牌名,不会出现一页叫 Levi's space、另一页叫 Levi 的植物手帐 的情况。

页面标题不要全站都写成同一句。推荐使用下面的规则:

Text
首页:Levi's space
文章:文章标题 · Levi's space
分类:分类名 · Levi's space
列表:全部文章 · Levi's space

摘要也不要复制首页描述。文章摘要应该来自文章自己的 article_description,分类页可以使用后台的分类描述,实在没有再使用默认文案。

五、第三步:给文章补上结构化数据

文章页现在输出 BlogPosting,里面包含:

  • 文章标题和摘要
  • 文章地址
  • 封面图
  • 作者
  • 发布时间和修改时间
  • 可选的文章关键词(补充 Schema.org 语义)
  • 所属网站

其中修改时间只读取后端明确表示“正文内容最后修改”的 content_updated_at。如果接口没有这个字段,页面不强行伪造 dateModified,只保留可靠的发布时间。

同时增加 BreadcrumbList

Text
首页 → 全部文章 → 当前文章

关键词可以放进 Schema.org 的 keywords,但它不是 Google Article 富结果的关键字段,也不会因为增加关键词字段就直接提升排名。真正重要的仍然是清晰的标题、正文内容、作者、发布时间、修改时间和封面图。

结构化数据不是保证获得富摘要的门票,但它能让搜索引擎更清楚地理解页面。这里有一个容易踩坑的地方:JSON-LD 必须是 <script> 标签的正文,不能只变成一个 children="..." 属性。因此代码使用的是 innerHTML: JSON.stringify(schema),上线后可以直接查看页面源代码确认。

验证文章页:

PowerShell
curl.exe -sS https://你的域名.com/posts/文章ID

在返回内容中搜索:

Text
application/ld+json
BlogPosting
BreadcrumbList
datePublished
dateModified

六、第四步:处理列表分页的 canonical

原来的列表页无论访问第几页,canonical 都是 /articles。现在改成:

Text
/articles       → canonical 是 /articles
/articles?page=2 → canonical 是 /articles?page=2
/articles?page=3 → canonical 是 /articles?page=3

同时分页页的 title 会带上页码,例如:

Text
全部文章 · 第 2 页 · Levi's space

这样搜索引擎可以理解每页都有不同的文章集合。不要为了“看起来更干净”把所有分页页都 canonical 到第一页,否则可能向搜索引擎发送错误的重复页面信号,影响分页页面的规范化判断和内容发现。分页页之间仍要使用可抓取的 <a href> 顺序链接。

七、第五步:搜索页设置 noindex

站内搜索页是给访客用的,不是给搜索引擎收录的。关键词组合非常多,收录后容易制造大量低价值、重复性强的 URL。

项目中使用:

TypeScript
useBlogSeo({
  title: '站内搜索',
  description: '搜索 Levi Blog 的文章。',
  robots: 'noindex,follow',
  canonical: '/search',
})

follow 仍然保留,搜索引擎可以继续沿着页面里的文章链接发现内容;noindex 则表示不要把搜索结果页本身放入索引。

robots.txt 只屏蔽 /api/,避免搜索引擎直接抓取接口响应;搜索页不能在 robots.txt 中屏蔽,因为 Google 必须先抓到页面,才能看到 noindex

八、第六步:增加动态 robots.txt 和 sitemap.xml

robots.txt

server/routes/robots.txt.ts 会输出:

Text
User-agent: *
Allow: /
Disallow: /api/
Sitemap: https://你的域名.com/sitemap.xml

因为它是服务端路由,所以域名会跟着 NUXT_PUBLIC_SITE_URL 变化,不需要每次换环境都手改静态文件。

sitemap.xml

server/routes/sitemap.xml.ts 会自动加入:

  • 首页
  • 文章列表
  • 归档页
  • 关于页、友链页、作品集
  • 所有有效分类页
  • 所有文章详情页

文章的内容最后修改时间会写进 <lastmod>;当前实现优先读取后端提供的 content_updated_at,如果没有这个字段,就使用 published_at。不要直接把会因阅读量、审核状态、同步时间等变化的普通 updated_at 当成 <lastmod>。Google 要求它尽量反映正文、结构化数据或重要链接等重大修改。

以后发布新文章,只要后端接口能返回它,sitemap 就会自动出现新地址,不需要再复制粘贴 XML。

本地验证:

PowerShell
curl.exe -sS http://127.0.0.1:3006/robots.txt
curl.exe -sS http://127.0.0.1:3006/sitemap.xml

如果博客后端暂时不可用,sitemap 仍会返回静态页面地址;后端恢复后再次访问,就会补回文章和分类地址。

九、图片和 HTML 内容也要配合

代码层面已经保留并统一了这些做法:

  • 文章封面使用文章标题作为 alt
  • 装饰性图片使用空 alt 并配合 aria-hidden
  • 图片尽量写上 widthheight,减少布局跳动
  • 非首屏图片使用 loading="lazy"
  • 图片使用 decoding="async"
  • 页面保持一个清晰、突出的主标题
  • 文章正文继续使用 h2h3 生成目录
  • 日期使用 <time datetime="...">

后台录入文章时,摘要比关键词更重要。摘要最好回答“这篇文章讲什么、适合谁、看完能做什么”,不要只写“记录一下”。例如:

Text
不太好:记录一下 Nuxt 的使用。
更好:从环境配置、页面路由到服务端渲染,带你搭建一个能被搜索引擎正常抓取的 Nuxt 4 博客页面。

关键词只保留真正出现在文章里的主题,三到六个通常已经够用。不要把无关热门词塞进文章里。

十、部署后按这个顺序检查

1. 检查构建

Shell
yarn build

只要 Nuxt 和 Nitro 构建通过,说明 SEO 代码至少能正常编译。

2. 检查页面源代码

随机挑一篇文章,确认有:

Text
<title>文章标题 · 站点名</title>
<meta name="description" ...>
<meta name="robots" content="index,follow,...">
<link rel="canonical" href="正式域名/posts/文章ID">
<meta property="og:image" ...>
<script type="application/ld+json">...</script>

3. 检查搜索页

访问 /search?keyword=vue,确认:

Text
<meta name="robots" content="noindex,follow">

4. 检查分页页

访问 /articles?page=2,确认 canonical 不是第一页,并且 title 带有“第 2 页”。

5. 提交站点地图

在 Google Search Console 或 Bing Webmaster Tools 中提交:

Text
https://你的域名.com/sitemap.xml

同时检查 robots.txt 是否被工具正常读取。第一次提交后不要频繁删除和重新提交,给搜索引擎一些抓取时间。

十一、以后写文章的 SEO 检查清单

每发布一篇文章,发布前花一分钟确认:

  • 标题能说清主题,尽量不要堆很多符号。
  • 摘要不是空的,能说明文章解决什么问题。
  • 文章正文有清晰的小标题。
  • 第一段尽早说明本文要解决的问题。
  • 封面图能正常打开,文章标题作为 alt 足够准确。
  • 标签只保留和正文有关的词。
  • 文章中有至少一两个站内相关文章链接。
  • 文章发布后能在 /sitemap.xml 中找到。
  • 如果文章下线,返回 404 或 301,不要留下一个空白的 200 页面。
阅读进度 0%