内容型独立站:Nuxt Content + Cloudflare Pages
一套零服务器成本的内容站起手式。Markdown 写作、边缘渲染、D1 存内容,从买域名到搜索引擎收录的完整配置与踩坑记录。
方案亮点
零服务器成本
Cloudflare Pages 的免费额度足够内容站跑,没有源站也就没有服务器要维护、打补丁。整个方案唯一要花钱的是域名。
全球边缘节点
请求由离访客最近的节点直接响应,不是从某个地域的源站回源,国内外访问速度不会差一个量级。
推送即部署
push 到 main 自动构建上线,不需要单独搭 CI,也不需要登服务器。
内容在 git 里
每次改动都有提交记录,能回滚、能 review,不需要单独备份数据库。
没有 CMS 后台
不存在登录入口和后台管理界面,少了一整类需要持续打补丁的攻击面。
- 适用场景
- 以文章为主体的内容站——案例库、文档站、个人博客、产品官网。不适合重交互的应用,也不适合需要长连接或常驻进程的服务。
- 起步耗时
- 半天搭好骨架,域名和收录再花半天
动手前要准备
- 一个域名,注册商随意,但 DNS 必须托管到 Cloudflare
- Cloudflare 账号(免费版够用)
- GitHub 仓库,用于 Pages 自动部署
- Google 账号,用于 Search Console 提交收录
技术栈
| 框架 | Nuxt 3 | SSR + 文件路由,Nitro 能直接编译成 Cloudflare Worker |
| 内容 | @nuxt/content v3 | Markdown 带 frontmatter,schema 用 zod 校验,字段写错构建就报错 |
| 内容增强 | MDC 组件 | 在 Markdown 里直接用 Vue 组件,比纯 Markdown 表达力强得多 |
| 数据库 | Cloudflare D1 | 内容在构建期灌进 D1,运行时查询;Workers 跑不了 better-sqlite3 |
| 样式 | Tailwind CSS v4 | 通过 Vite 插件接入,不需要 PostCSS 配置 |
| 国际化 | @nuxtjs/i18n | 单语言也建议装上,以后加语言不用改 URL |
| SEO | @nuxtjs/sitemap + @nuxtjs/robots | 共用 nuxt-site-config 的站点配置 |
| 域名 | GoDaddy(或任意注册商) | 只负责注册,买完把 DNS 交给 Cloudflare;在哪注册不影响后面的步骤 |
| DNS | Cloudflare DNS | 必须把 nameserver 换到 Cloudflare,否则根域名无法指向 Pages |
| 代码托管 | GitHub | Pages 直连仓库,push 到 main 自动构建部署,不需要额外 CI |
| 部署 | Cloudflare Pages | 免费额度足够,边缘渲染,无源站 |
| 收录 | Google Search Console | 验证域名 + 提交 sitemap;robots 放行不等于 Google 知道你存在 |
| 包管理 | pnpm workspace | monorepo 结构,方便以后加 admin 等第二个应用 |
| 开发 | Claude Code | 全程 AI 结对开发;把重复的内容生产流程固化成仓库内的 skill |
这套模板解决什么问题
内容站的常见困境:用 WordPress 要养服务器、要打补丁;用纯静态生成器又缺服务端能力(重定向、动态 sitemap、按需渲染)。
这套组合的取舍是:内容用 Markdown 管在 git 里,渲染跑在边缘节点上,成本接近零。
写作时改的是 .md 文件,走 git 提交、有历史、能回滚、能 review。部署后每个请求由 Cloudflare Worker 就近响应,没有源站,也就没有服务器要维护。
从零复现
版本以 2026-08 为准
Nuxt 生态版本漂移很快,下面的配置写法对应的是这组版本。 照抄时先对齐版本,再改内容——版本不对时配置项的名字和默认值都可能变。
一、起项目
mkdir my-site && cd my-site
pnpm init
根 package.json:
{
"name": "my-site",
"private": true,
"packageManager": "pnpm@8.6.12",
"scripts": {
"dev": "pnpm --filter web dev",
"build": "pnpm --filter web build",
"build:cf": "NITRO_PRESET=cloudflare_pages pnpm --filter web build",
"preview:cf": "cd apps/web && pnpm dlx wrangler@latest pages dev dist --d1 DB"
}
}
pnpm-workspace.yaml:
packages:
- "apps/*"
- "packages/*"
然后建应用:
pnpm dlx nuxi@latest init apps/web
二、装依赖
cd apps/web
# 内容与 SEO
pnpm add @nuxt/content @nuxtjs/i18n @nuxtjs/sitemap @nuxtjs/robots
# 样式与本地数据库
pnpm add @tailwindcss/vite tailwindcss better-sqlite3
pnpm add -D typescript vue-tsc
对应的版本:
{
"dependencies": {
"@nuxt/content": "^3.15.2",
"@nuxtjs/i18n": "^10.6.0",
"@nuxtjs/robots": "^6.2.0",
"@nuxtjs/sitemap": "^8.5.0",
"@tailwindcss/vite": "^4.3.3",
"better-sqlite3": "^12.5.0",
"nuxt": "^3.21.11",
"tailwindcss": "^4.3.3",
"vue": "^3.5.41",
"vue-router": "^4.5.0"
},
"devDependencies": {
"typescript": "^5.9.0",
"vue-tsc": "^2.2.0"
}
}
better-sqlite3 是 @nuxt/content 的 peer dependency,本地开发要用,别漏。
它只在本地生效,Cloudflare 上走 D1。
三、nuxt.config.ts
这是整套配置的核心,逐块都有对应的坑,后面会展开:
import tailwindcss from '@tailwindcss/vite'
// 构建期求值,不是运行时。生产构建默认用正式域名,
// 否则 canonical 和 sitemap 会全写成 localhost
const siteUrl
= process.env.NUXT_PUBLIC_SITE_URL
|| (process.env.NODE_ENV === 'production'
? 'https://your-domain.com'
: 'http://localhost:3000')
export default defineNuxtConfig({
modules: ['@nuxt/content', '@nuxtjs/i18n', '@nuxtjs/sitemap', '@nuxtjs/robots'],
compatibilityDate: '2025-01-01',
devServer: { port: 3100 },
css: ['~/assets/css/main.css'],
vite: { plugins: [tailwindcss()] },
// 图片走静态资源,不进 worker
nitro: {
cloudflare: { pages: { routes: { exclude: ['/images/*'] } } },
},
runtimeConfig: { public: { siteUrl } },
// sitemap / robots / canonical 共用这份站点配置
site: {
url: siteUrl,
name: '站名',
// 真正的收录开关,见坑一
indexable: process.env.NUXT_PUBLIC_INDEXABLE !== 'false',
},
content: {
// 本地 SQLite,Cloudflare 上切 D1
database: process.env.NUXT_CONTENT_D1_BINDING
? { type: 'd1', bindingName: process.env.NUXT_CONTENT_D1_BINDING }
: undefined,
build: {
markdown: {
toc: { depth: 3, searchDepth: 3 },
highlight: {
theme: { default: 'github-light', dark: 'github-dark' },
langs: ['js', 'ts', 'vue', 'json', 'bash', 'sh', 'html', 'css',
'sql', 'yaml', 'python', 'diff', 'md'],
},
},
},
},
i18n: {
baseUrl: siteUrl,
locales: [
{ code: 'zh', language: 'zh-Hans', name: '中文', dir: 'ltr', file: 'zh.json' },
],
defaultLocale: 'zh',
strategy: 'prefix_except_default', // 默认语言不带前缀,见坑五
detectBrowserLanguage: false,
},
sitemap: {
sources: ['/api/__sitemap__/urls'], // 动态路由要手动补,见坑四
},
robots: {
disallow: process.env.NUXT_PUBLIC_INDEXABLE === 'false' ? ['/'] : [],
// 默认 4 小时太长,改完 robots 要等边缘缓存过期才生效
cacheControl: 'max-age=600, must-revalidate',
},
})
四、content.config.ts
内容的字段规则全在这里。schema 用 zod,字段写错构建直接报错, 不会等到线上才发现:
import { defineContentConfig, defineCollection, z } from '@nuxt/content'
const stackItem = z.object({
layer: z.string(), // 前端 / 后端 / 支付 / 部署
name: z.string(),
note: z.string().optional(),
})
const caseSchema = z.object({
title: z.string(),
description: z.string(),
date: z.string(),
updated: z.string().optional(),
category: z.string(),
tags: z.array(z.string()).default([]),
sourceUrl: z.string().optional(),
sourceName: z.string().optional(),
stack: z.array(stackItem).default([]),
featured: z.boolean().default(false),
draft: z.boolean().default(false),
})
export default defineContentConfig({
collections: {
// source 里的 zh 是内容的语言分区,跟 URL 前缀无关
casesZh: defineCollection({ type: 'page', source: 'zh/cases/**', schema: caseSchema }),
pagesZh: defineCollection({ type: 'page', source: 'zh/pages/**' }),
},
})
加一个内容类型 = 在这里加一个 collection,其余照着已有的抄。
五、环境变量
.env.example:
# 站点地址。sitemap / canonical / hreflang 都用它
NUXT_PUBLIC_SITE_URL=https://your-domain.com
# 默认允许收录。设为 false 才屏蔽搜索引擎(大改版时临时用)
NUXT_PUBLIC_INDEXABLE=true
# Cloudflare D1 binding 名。不设置则本地用 SQLite 文件
NUXT_CONTENT_D1_BINDING=
只有三个。生产环境这三个都可以不配——nuxt.config.ts 里的默认值
已经覆盖了正确行为,这是刻意设计的,避免忘配变量导致线上出错。
六、部署
Cloudflare Pages 连上 GitHub 仓库,填三项设置(见后面「关键配置」),
建一个 D1 数据库并绑定为 DB,然后推送到 main 即可。
本地想验证 Cloudflare 产物:
pnpm run build:cf
pnpm run preview:cf # 带本地 D1,行为与线上一致
目录结构
没有后台,目录结构就是操作界面
这套模板不带管理端。新建文章、放图片、改导航,全靠知道文件该放哪。 所以这一节要当作操作手册看,不是装饰。
apps/web/
├── content/ # ← 写作只动这里
│ └── zh/ # 按语言分目录,语言代码即目录名
│ ├── cases/ # 案例,一个 .md 一篇
│ ├── solutions/ # 技术方案,slug 与案例一一对应
│ ├── templates/ # 开发模板
│ └── pages/ # 关于、披露声明等单页
│
├── public/ # ← 静态资源,原样拷进产物
│ ├── images/ # 插图放这儿,正文用 /images/xxx.png 引用
│ └── favicon.ico
│
├── content.config.ts # ← 内容 schema,字段规则的唯一出处
│
├── pages/ # 文件路由,路径即 URL
│ ├── index.vue # /
│ ├── about.vue # /about
│ ├── disclosure.vue # /disclosure
│ ├── cases/
│ │ ├── index.vue # /cases
│ │ └── [slug]/
│ │ ├── index.vue # /cases/xxx
│ │ └── solution.vue # /cases/xxx/solution
│ └── templates/
│ ├── index.vue
│ └── [slug].vue
│
├── components/
│ ├── content/ # ← MDC 组件,可在 Markdown 里直接用
│ │ ├── Callout.vue # ::callout
│ │ ├── Figure.vue # ::figure 带图注的插图
│ │ ├── Prompt.vue # ::prompt
│ │ ├── Stack.vue # ::stack
│ │ └── Steps.vue # ::steps
│ ├── AppHeader.vue # 导航栏,加模块要改这里
│ ├── AppFooter.vue
│ ├── CaseCard.vue
│ └── TemplateCard.vue
│
├── composables/ # 数据访问层,页面不直接查库
│ ├── useCases.ts
│ └── useTemplates.ts
│
├── constants/ # ← 分类定义,加分类只改这里
│ ├── categories.ts # 案例分类:按变现模式
│ └── template-kinds.ts # 模板分类:按产物类型
│
├── i18n/locales/zh.json # ← 界面文案,所有 t('xxx') 的出处
├── assets/css/main.css # 全局样式与 .prose 排版
├── layouts/default.vue
├── server/api/__sitemap__/ # 动态路由的 sitemap 来源
├── types/content.ts # 与 schema 对应的 TS 类型
└── nuxt.config.ts
标 ← 的六个位置是日常真正会碰的,其余基本一次写好不用再动。
常见操作对应改哪里
| 想做什么 | 动哪里 |
|---|---|
| 加一篇案例 | content/zh/cases/<slug>.md |
| 给案例配技术方案 | content/zh/solutions/<slug>.md,slug 必须与案例相同 |
| 插一张图 | 文件放 public/images/,正文写 /images/xxx.png |
| 加一个内容分类 | constants/ 下对应文件加一行,再到 i18n/locales/zh.json 补标签 |
| 改界面上的字 | i18n/locales/zh.json |
| 给内容加一个字段 | content.config.ts 改 schema,types/content.ts 同步类型 |
| 加一个顶级模块 | 新建 collection + pages/ 下建目录 + AppHeader.vue 加入口 + sitemap 源补上 |
最后一行那四步缺一不可。少了 sitemap 那步,新模块的页面能访问但搜索引擎不知道它存在。
插图必须用绝对路径
正文写 /images/xxx.png,不能用相对路径。Markdown 文件在
content/zh/cases/ 下,相对路径会被算成 ../../../public/images/...,
浏览器解析不出来。
VS Code 拖拽插入图片时默认给的就是相对路径,得手动改。
MDC 组件的花括号陷阱
自定义组件的属性写在 {} 里,所以 MDC 会把正文里裸露的花括号
也当成属性语法吃掉。放提示词、JSON、模板占位符时,正文一定要用代码围栏包住,
否则内容会被静默破坏。
路径里的 zh 不等于 URL 里的 zh
表里的 content/zh/... 和 i18n/locales/zh.json 都带语言代码,
但对外 URL 是 /cases/xxx,没有前缀。这两个 zh 互相独立:
- 文件系统的 zh 是内容的语言分区,由 collection 的
source: 'zh/cases/**'决定 - URL 里的 zh 由 i18n 的
strategy决定,prefix_except_default下默认语言不带前缀
改路由策略不会动目录结构,把 zh 目录删了则内容一条都查不到。
关于 monorepo
用 apps/web 这层嵌套不是为了炫技,是因为内容站早晚要长出第二个应用——
管理后台、抓取脚本、文档站。一开始就分好,比长出来之后再拆便宜。
代价是 Cloudflare Pages 的 Build output directory 要写成 apps/web/dist,
这是这套结构里最容易配错的一项。
用 Claude Code 开发
这套模板从搭骨架到上线全程用 Claude Code 完成。有两个做法值得抄。
第一,把重复流程固化成仓库内的 skill。
本站每加一篇案例,要产出格式一致的两个文件:案例分析和技术方案,
frontmatter 字段、六维评分口径、MDC 组件用法都有固定规矩。
这些规矩写在 .claude/skills/ 里:
.claude/skills/case-breakdown/
├── SKILL.md # 触发条件与工作流
└── references/
├── frontmatter.md # 字段规范
├── scoring.md # 评分口径
├── mdc.md # 组件用法
└── solution-guide.md # 技术方案的写法
好处是规矩只写一次。丢一个案例链接过去,产出的文件格式就是对的, 不用每次在对话里重复交代「评分怎么打」「哪些字段必填」。
第二,让它自己验证,别只看它说做完了。
这个项目里最难缠的几个 bug——收录开关不生效、canonical 写成 localhost、 改 URL 结构后 sitemap 静默塌掉——没有一个会报错,页面全是 200, 只有实际去查输出才看得见。
所以每次改完都跑一遍前面那份检查清单的 curl 命令,用返回值判断, 不用「应该没问题」判断。上面八个坑里有五个是这样抓出来的。
域名接入
这一步在所有配置之前,也是最容易卡住的一步。
注册商随便挑,GoDaddy、Namecheap、Cloudflare 自己都行。但注册完之后, 必须把 nameserver 换到 Cloudflare,原因是:
根域名(apex,即不带 www 的 example.com)不能用 CNAME,这是 DNS 协议的限制。
而 Pages 给你的地址是 xxx.pages.dev 这种域名,不是固定 IP。
只有 Cloudflare 托管 DNS 时才有 CNAME flattening,能让根域名指向一个域名。
换 nameserver 的位置在注册商的「域名服务器」设置页,不是「DNS 记录」页—— 用着注册商自家 nameserver 时,记录页里的 NS 记录是只读的,在那儿改不动。
换之前先关掉 DNSSEC
注册商侧如果开着 DNSSEC,换到 Cloudflare 后域名会直接解析失败。 这是 NS 迁移最常见的翻车点。Cloudflare 那边稳定后可以再开。
DNS 生效后,到 Pages 项目的 Custom domains 里把根域名和 www 都加一遍。
只在 DNS 里加 CNAME 是不够的:Pages 收到请求时会检查 Host 是否在自己的自定义域列表里,
不在就不接这个连接,表现为 522。根域名能通而 www 报 522,八成就是漏加了这一条。
关键配置
构建命令
Cloudflare Pages 项目设置里填这三项,Build output directory 是最容易错的一项:
| 项 | 值 |
|---|---|
| Build command | pnpm install --filter web... && pnpm run build:cf |
| Build output directory | apps/web/dist |
| Root directory | 留空 |
build:cf 就是带上预设:
NITRO_PRESET=cloudflare_pages nuxt build
不带这个预设时产物在 .output/,里面只有静态资源没有 worker。Pages 会把它当纯静态站伺服,而 / 需要服务端渲染,静态目录里没有对应的 index.html,结果是全站 404。
判断 worker 有没有真正跑起来:访问 /robots.txt,如果返回的是 Cloudflare 的兜底内容而不是你生成的,说明请求根本没进 worker。
内容数据库切 D1
本地开发用 SQLite 文件,Workers 上跑不了 better-sqlite3,必须切 D1:
content: {
database: process.env.NUXT_CONTENT_D1_BINDING
? { type: 'd1', bindingName: process.env.NUXT_CONTENT_D1_BINDING }
: undefined,
}
在 Cloudflare 建好 D1 后,到项目 Settings → Bindings 添加,变量名用 DB(@nuxt/content 的默认绑定名)。
绑定加完必须重新部署
绑定不会作用到已有部署上。加完绑定不重新部署,线上还是找不到数据库。
另外绑定要给 Production 和 Preview 各加一次。只加 Production 的话, 分支预览部署会全部挂掉,而且报错信息看不出是绑定缺失。
内容不需要手动导入,首次请求时会自动把 dump.*.sql 灌进 D1。
静态资源绕过 worker
nitro: {
cloudflare: { pages: { routes: { exclude: ['/images/*'] } } },
}
不排除的话,每张图片都会触发一次 worker 调用——既慢又消耗配额,而静态资源本该由 CDN 直接吐。
八个必踩的坑
这些不是理论推演,是这个站上线过程中真实踩出来的。
一、收录开关不是 robots.disallow
想让搜索引擎收录,直觉是去改 robots.disallow。改了没用。
@nuxtjs/robots 的 robots.txt 路由在 indexable 为 false 时会硬写 disallow: ['/'],根本不读你的配置。而 indexable 由 nuxt-site-config 决定,不显式设置时回落到 env === 'production'——Cloudflare Workers 运行时探测不到这个,于是永远是 false。
真正的开关:
site: {
url: siteUrl,
indexable: process.env.NUXT_PUBLIC_INDEXABLE !== 'false',
}
验证方式:看 robots.txt 里的标记是 # START nuxt-robots (indexable) 还是 (indexing disabled)。
二、siteUrl 会静默回落到 localhost
const siteUrl = process.env.NUXT_PUBLIC_SITE_URL || 'http://localhost:3000'
这行看着无害,但如果忘了在 Pages 配环境变量,线上每个页面的 canonical、og:url 和 sitemap 里的地址全都写成 localhost。收录一打开,等于告诉 Google「本站的规范地址是 localhost」。
而且它不报错,页面照常渲染,只有查 HTML 源码才看得见。
const siteUrl
= process.env.NUXT_PUBLIC_SITE_URL
|| (process.env.NODE_ENV === 'production'
? 'https://your-domain.com'
: 'http://localhost:3000')
三、内容路径与对外 URL 是两套地址
内容按语言分目录,所以 doc.path 天然带前缀:
| 值 | |
|---|---|
| 文件 | content/zh/cases/foo.md |
doc.path | /zh/cases/foo |
| 对外 URL | /cases/foo |
任何拿 doc.path 直接当 URL 用的地方都会生成旧地址——sitemap、
交叉引用链接、结构化数据都中招。写一个转换函数,别在多处硬编码:
const toRoute = (path: string) => path.replace(/^\/zh(?=\/|$)/, '') || '/'
solutions 更麻烦,内容路径是 /zh/solutions/<slug>,
对外路由却是 /cases/<slug>/solution,两层都要转。
四、动态路由不在 sitemap 里
@nuxtjs/sitemap 只能自动发现静态页面文件。/cases/[slug] 这类参数化路由它扫不出来,结果 sitemap 里只有几个列表页,站上真正的正文一条都没有。
补一个 server 路由把内容集合翻成 URL:
sitemap: { sources: ['/api/__sitemap__/urls'] }
改 URL 结构时这里不会自动跟着变
这段是独立于 i18n 配置的硬编码逻辑。改路由策略之后如果忘了同步, sitemap 会静默地把正文页全部丢掉——页面还是 200,只是没人告诉搜索引擎它们存在。
五、i18n 策略决定 URL,改晚了要付迁移成本
单语言站如果用 strategy: 'prefix',每个 URL 都会挂着无意义的语言前缀,根路径还要 302 跳一次。
i18n: {
defaultLocale: 'zh',
strategy: 'prefix_except_default', // 默认语言不带前缀
}
这样中文落在 /,以后加英文是 /en/...,中文 URL 一个都不用动。
关键是在被搜索引擎收录之前定下来。收录之后再改,要配全站 301 加上几周的重新索引期。
已经分享出去的旧链接用 routeRules 兜住:
routeRules: {
'/zh': { redirect: { to: '/', statusCode: 301 } },
'/zh/**': { redirect: { to: '/**', statusCode: 301 } },
}
_redirects 文件在 worker 模式下不参与
Nitro 会把上面的规则同时写进 dist/_redirects,而且用的是自己的
/** 语法——Cloudflare 的 _redirects 其实要 :splat,两者不兼容。
但这不影响:产物里有 _worker.js 就是 advanced mode,
_routes.json 的 include: ["/*"] 让所有请求都进 worker,
_redirects 根本不参与。真正生效的是 worker 里的运行时规则。
不知道这点的话,会盯着那个语法不对的文件白排查半天。
六、robots.txt 默认缓存 4 小时
改完收录配置部署上去,robots.txt 纹丝不动;过一会儿刷新又变了,再刷新又回去了。
@nuxtjs/robots 给 robots.txt 设的是 max-age=14400。边缘各节点的缓存
新旧不一,你打到哪个节点就看到哪个版本,排查时会以为自己见了鬼。
robots: {
cacheControl: 'max-age=600, must-revalidate',
}
这文件才几百字节,缓存命中率没有意义,缩短换取改动即时生效。 已经缓存的那份要么等它过期,要么去 Cloudflare 的 Caching → Configuration 做一次 Purge。
Purge Cache 是域名级功能
它在**网站(域名)**的设置里,不在 Workers & Pages 的项目下。 在 Pages 项目里翻遍了也找不到。
七、robots.txt 里会冒出你没写过的内容
第一次打开自己的 robots.txt,会看到一大段没写过的东西:一堆
GPTBot / ClaudeBot / Google-Extended 的 Disallow,还有
Content-Signal 声明和一段版权声明注释。
这是 Cloudflare 注入的托管内容,不是你的配置出错,标在
# BEGIN Cloudflare Managed content 区块里。它默认帮你挡住 AI 爬虫。
两点要知道:
- 不影响搜索收录。 负责索引的是
Googlebot,不在拦截名单里;Google-Extended管的是拿内容训练模型,是另一回事。 - 想关在 Cloudflare 域名设置里,跟代码无关,改
nuxt.config.ts没用。
对刚上线的新站,被 AI 助手引用其实是一条真实的流量来源,值得考虑放开 面向问答的那几个爬虫。
八、本地代理会缓存 DNS,骗你几个小时
换 DNS 或换 nameserver 之后,浏览器一直显示旧站,dscacheutil -flushcache 也没用。
因为 Clash 这类代理客户端自己做 DNS 解析,完全绕过系统缓存;开了 fake-ip 的话映射还会落盘,重启电脑都还在。
排查时用 curl 绕过代理,这是最干净的判据:
curl -sI --noproxy '*' https://your-domain.com/ | head -3
返回对了就说明服务端没问题,剩下的都是本机缓存问题。
上线检查清单
按顺序过一遍,每一条都能用命令验证:
# nameserver 换过来了吗(应返回 *.ns.cloudflare.com)
dig +short NS your-domain.com
# 根域名和 www 都能通吗(www 报 522 = 漏加自定义域)
curl -sI --noproxy '*' https://www.your-domain.com/ | head -1
# worker 跑起来了吗(不是 404、不是 Cloudflare 兜底页)
curl -sI --noproxy '*' https://your-domain.com/ | head -3
# 收录开了吗(要看到 indexable)
curl -s --noproxy '*' https://your-domain.com/robots.txt | grep "START nuxt-robots"
# canonical 是正式域名还是 localhost
curl -s --noproxy '*' https://your-domain.com/ | grep -c "localhost"
# sitemap 里有几条,正文页在不在
curl -s --noproxy '*' -L https://your-domain.com/sitemap.xml | grep -c "<loc>"
每条都跑通了再提交收录。
最后一步是去 Google Search Console 提交 sitemap。用 DNS TXT 记录验证域名所有权 最省事——DNS 本来就在 Cloudflare,加一条记录几分钟生效。robots.txt 放行只是撤掉了路障,不等于 Google 知道你存在——新域名没有任何外链时,不主动提交它根本找不到你。