这篇文章记录的是一次真实的 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:
# 博客后端地址
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。部署后先访问:
https://你的域名.com/robots.txt
https://你的域名.com/sitemap.xml
检查返回的地址是否已经是正式域名。
四、第二步:统一管理页面 SEO
不要在每个页面里重复写一套 head。项目使用 app/composables/useBlogSeo.ts 统一处理:
useBlogSeo({
title: '文章标题',
description: '文章摘要,尽量用一句话说明读者能得到什么。',
image: articleCover,
type: 'article',
publishedTime: articlePublishedAt,
modifiedTime: articleUpdatedAt,
})
这套函数现在会生成:
<title>descriptionrobotscanonical- Open Graph:标题、摘要、类型、地址、站点名、图片和图片 alt
- Twitter Card
zh-CN页面语言信息WebPage或BlogPostingJSON-LD
传入的 title 是页面自己的标题,组合式函数会自动从后台站点设置中读取博客名称并拼到后面。这样首页、文章页和分类页使用的是同一个品牌名,不会出现一页叫 Levi's space、另一页叫 Levi 的植物手帐 的情况。
页面标题不要全站都写成同一句。推荐使用下面的规则:
首页:Levi's space
文章:文章标题 · Levi's space
分类:分类名 · Levi's space
列表:全部文章 · Levi's space
摘要也不要复制首页描述。文章摘要应该来自文章自己的 article_description,分类页可以使用后台的分类描述,实在没有再使用默认文案。
五、第三步:给文章补上结构化数据
文章页现在输出 BlogPosting,里面包含:
- 文章标题和摘要
- 文章地址
- 封面图
- 作者
- 发布时间和修改时间
- 可选的文章关键词(补充 Schema.org 语义)
- 所属网站
其中修改时间只读取后端明确表示“正文内容最后修改”的 content_updated_at。如果接口没有这个字段,页面不强行伪造 dateModified,只保留可靠的发布时间。
同时增加 BreadcrumbList:
首页 → 全部文章 → 当前文章
关键词可以放进 Schema.org 的 keywords,但它不是 Google Article 富结果的关键字段,也不会因为增加关键词字段就直接提升排名。真正重要的仍然是清晰的标题、正文内容、作者、发布时间、修改时间和封面图。
结构化数据不是保证获得富摘要的门票,但它能让搜索引擎更清楚地理解页面。这里有一个容易踩坑的地方:JSON-LD 必须是 <script> 标签的正文,不能只变成一个 children="..." 属性。因此代码使用的是 innerHTML: JSON.stringify(schema),上线后可以直接查看页面源代码确认。
验证文章页:
curl.exe -sS https://你的域名.com/posts/文章ID
在返回内容中搜索:
application/ld+json
BlogPosting
BreadcrumbList
datePublished
dateModified
六、第四步:处理列表分页的 canonical
原来的列表页无论访问第几页,canonical 都是 /articles。现在改成:
/articles → canonical 是 /articles
/articles?page=2 → canonical 是 /articles?page=2
/articles?page=3 → canonical 是 /articles?page=3
同时分页页的 title 会带上页码,例如:
全部文章 · 第 2 页 · Levi's space
这样搜索引擎可以理解每页都有不同的文章集合。不要为了“看起来更干净”把所有分页页都 canonical 到第一页,否则可能向搜索引擎发送错误的重复页面信号,影响分页页面的规范化判断和内容发现。分页页之间仍要使用可抓取的 <a href> 顺序链接。
七、第五步:搜索页设置 noindex
站内搜索页是给访客用的,不是给搜索引擎收录的。关键词组合非常多,收录后容易制造大量低价值、重复性强的 URL。
项目中使用:
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 会输出:
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。
本地验证:
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 - 图片尽量写上
width和height,减少布局跳动 - 非首屏图片使用
loading="lazy" - 图片使用
decoding="async" - 页面保持一个清晰、突出的主标题
- 文章正文继续使用
h2、h3生成目录 - 日期使用
<time datetime="...">
后台录入文章时,摘要比关键词更重要。摘要最好回答“这篇文章讲什么、适合谁、看完能做什么”,不要只写“记录一下”。例如:
不太好:记录一下 Nuxt 的使用。
更好:从环境配置、页面路由到服务端渲染,带你搭建一个能被搜索引擎正常抓取的 Nuxt 4 博客页面。
关键词只保留真正出现在文章里的主题,三到六个通常已经够用。不要把无关热门词塞进文章里。
十、部署后按这个顺序检查
1. 检查构建
yarn build
只要 Nuxt 和 Nitro 构建通过,说明 SEO 代码至少能正常编译。
2. 检查页面源代码
随机挑一篇文章,确认有:
<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,确认:
<meta name="robots" content="noindex,follow">
4. 检查分页页
访问 /articles?page=2,确认 canonical 不是第一页,并且 title 带有“第 2 页”。
5. 提交站点地图
在 Google Search Console 或 Bing Webmaster Tools 中提交:
https://你的域名.com/sitemap.xml
同时检查 robots.txt 是否被工具正常读取。第一次提交后不要频繁删除和重新提交,给搜索引擎一些抓取时间。
十一、以后写文章的 SEO 检查清单
每发布一篇文章,发布前花一分钟确认:
- 标题能说清主题,尽量不要堆很多符号。
- 摘要不是空的,能说明文章解决什么问题。
- 文章正文有清晰的小标题。
- 第一段尽早说明本文要解决的问题。
- 封面图能正常打开,文章标题作为 alt 足够准确。
- 标签只保留和正文有关的词。
- 文章中有至少一两个站内相关文章链接。
- 文章发布后能在
/sitemap.xml中找到。 - 如果文章下线,返回 404 或 301,不要留下一个空白的 200 页面。
private note
交流
文章暂不开放公开评论。如果你有想法、问题或建议,欢迎私下联系站长。
联系站长 →