使用 Next.js 与 Sanity CMS 构建 Corsair 官网博客:配置、内容模型与按需重新验证全解析
【免费下载链接】corsairConnect your users to their apps项目地址: https://gitcode.com/GitHub_Trending/corsa/corsair
本篇技术指南以 Corsair 开源仓库中的官网(www/目录)为对象,完整讲解其基于 Next.js 15 + Sanity CMS 的博客架构:从本地环境搭建、Sanity 项目初始化、环境变量体系,到post内容模型、GROQ 数据读取、按需重新验证(On-demand Revalidation)以及 Markdown 种子文章迁移流程。读完本文,你将掌握一套可直接复用的"Next.js + Sanity + Portable Text"内容中台搭建方案,并能在自己的项目中落地同样的配置与工作流。
项目概览:官网技术构成
Corsair 官网位于仓库的 www 目录,是一个 Next.js 应用,其 README 明确说明落地页视觉风格深受 twenty.com 启发,而博客内容全部托管在 Sanity CMS 中,正文使用 Portable Text 结构化富文本存储。从 www/package.json 的依赖可以看到完整技术栈:
- Next.js 15(
^15.3.2)+ React 19,开发服务器通过next dev --turbopack启动; - Sanity 4(
^4.22.0)作为 CMS,配合next-sanity(^11.6.13)做数据读取与 webhook 签名解析; - Drizzle ORM(
^0.44.5)管理自身数据库(认证、集成目录、OSS 认领等业务表); - Inngest(
^3.54.0)承担后台任务,better-auth承担认证,tRPC v11承担 API 层; - Tailwind CSS 4负责样式,搭配
styled-components与 shadcn 组件体系。
其中博客链路(Sanity)与业务链路(Drizzle 数据库)相互独立:Sanity 只承载博客文章,官网的集成目录、OSS 认领等功能则落在 Postgres + Drizzle 上,这一点从 www/src/db 目录下的多个 schema 文件可以看出。
本地开发环境搭建
README 给出的三步启动流程如下:
cp .env.example .env pnpm install pnpm devpnpm dev实际执行的是next dev --turbopack(见 www/package.json),即以 Turbopack 为打包器启动 Next.js 开发服务器。仓库使用 pnpm workspace 管理多包,因此必须在仓库根目录安装依赖后进入www子包运行脚本,corsair与@corsair-dev/*系列包均以workspace:*形式引用。
环境变量一览
官网的环境变量由 www/sanity/env.ts 统一定义并提供默认值:
| 变量 | 用途 | 默认值 |
|---|---|---|
NEXT_PUBLIC_SANITY_PROJECT_ID | Sanity 项目 ID,必填,缺失时assertSanityEnv()会直接抛错 | 无 |
NEXT_PUBLIC_SANITY_DATASET | Sanity 数据集名称 | production |
NEXT_PUBLIC_SANITY_API_VERSION | Sanity API 版本号 | 2025-01-01 |
SANITY_API_WRITE_TOKEN | 写操作 API Token(迁移、写入用) | 无 |
SANITY_REVALIDATE_SECRET | 按需重新验证 Webhook 的签名密钥 | 无 |
在 www/src/lib/sanity/client.ts 中,读写客户端是分离的:getSanityClient()用于公开读取,在生产环境(NODE_ENV === 'production')自动启用 CDN;getSanityWriteClient()仅在配置了SANITY_API_WRITE_TOKEN时返回,且强制useCdn: false,确保写入基于最新数据。
Sanity 首次配置:从零到可发布
README 给出了完整的首次配置八步流程,下面结合源码逐项展开:
- 创建 Sanity 项目:在 Sanity Manage 控制台新建项目。
- 填入 Project ID:将项目 ID 写入
.env的NEXT_PUBLIC_SANITY_PROJECT_ID。 - 创建 API Token:在 Project → API → Tokens 中新建 Token,权限选Editor,写入
SANITY_API_WRITE_TOKEN。迁移脚本 www/scripts/migrate-blog-to-sanity.ts 的错误提示中特别强调:这里的 Editor 指 API Token 的权限类型,而不是项目管理成员角色,Manager 是给人用的项目成员角色,不能作为 API Token 类型使用。 - 配置 CORS 白名单:在 Project → API → CORS 中依次添加:
http://localhost:3000(本地开发)https://corsair.dev(生产环境)
- 部署 Schema:
pnpm sanity:schema该命令等价于
sanity schema deploy,把 www/sanity/schemaTypes/post.ts 中定义的post文档类型推送到远端项目。 - 迁移种子文章(可选):
pnpm blog:migrate将
www/scripts/seed-data/下的 Markdown 文件导入 Sanity(详见下文迁移章节)。 - 打开本地 Studio:访问
http://localhost:3000/studio。
Studio 的本地配置在 www/sanity.config.ts 中:basePath设为/studio,启用structureTool并挂载自定义结构,开发模式下额外启用visionTool方便调试 GROQ 查询。Sanity CLI 配置 www/sanity.cli.ts 指定了项目 ID、数据集以及托管 Studio 的域名前缀corsair-www。
邀请编辑(以 SEO 经理为例)
- 在 Sanity Manage → 项目 →Members中邀请协作者,角色选择Editor;
- 将 Studio 地址
https://corsair.dev/studio分享给对方。
编辑无需接触代码仓库即可撰写、修改文章,这正是把内容交给 CMS 而非 git 的核心收益。
内容模型:post 文档类型详解
博客的内容契约定义在 www/sanity/schemaTypes/post.ts。post文档被划分为两个分组(groups):
- content(内容):
title(必填标题)、slug(由标题自动生成,最长 96 字符,注释标明最终 URL 形如/resources/blog/<slug>)、description(摘要,3 行文本框,必填且不超过 300 字符,用作博客列表与 meta description 回退)、author(默认值Corsair Team)、publishedAt(发布时间)、coverImage(封面图,开启 hotspot 裁剪,带 alt 字段)、body(正文,必填)、faqs(可选 FAQ 数组,用于生成 FAQPage JSON-LD)。 - seo(SEO 与元数据):
metaTitle(浏览器标签与搜索结果标题,约 60 字符)、metaDescription(搜索结果摘要,约 155 字符)、targetKeywords(目标关键词标签数组)、socialShareImage(社交分享图)、jsonLd(原始 JSON-LD,可注入一个或多个结构化数据对象,如 Article + FAQPage)。
Portable Text 正文约束
body字段通过defineArrayMember严格限制了可用的富文本能力,与 README 中"内容模型"一节完全对应:
- 块级样式:Normal、H2、H3、blockquote(引用);
- 列表:bullet(无序)、number(有序);
- 行内标记:strong(加粗)、em(斜体)、code(行内代码),以及 link 注解(
href允许相对路径,scheme 限定http、https、mailto); - 行内图片:支持 alt 文本(必填)与可选 caption 说明。
这些约束在渲染端由 www/src/components/blog/portable-text-components.tsx 一一映射为 Next.js 组件:h2/h3/blockquote/normal各自拥有排版类名,link组件会根据 href 是否为绝对地址自动决定是否添加target="_blank"与noreferrer noopener,图片通过urlForImage走 Sanity 图片 CDN。也就是说,编辑在 Studio 里能用的样式,就是渲染端保证有对应 UI 的样式,两端契约天然一致。
博客数据读取:GROQ 查询与页面渲染
博客的数据读取集中在 www/src/lib/sanity/queries.ts,使用next-sanity的defineQuery定义了三组 GROQ 查询:
allPostsQuery:按publishedAt降序拉取全部文章,字段含_id、title、slug.current、description、author、publishedAt、body;postBySlugQuery:按 slug 精确取单篇文章;allPostSlugsQuery:仅取 slug 列表,用于生成静态路径。
博客首页 www/src/app/blog/page.tsx 调用getAllPosts()渲染文章卡片列表,并内置了空态处理:当posts.length === 0时展示"No articles yet"的虚线占位框。文章详情页位于 www/src/app/blog/[slug]/page.tsx,正文部分由 Portable Text 渲染组件消费。
按需重新验证:发布即生效
由于博客页面在构建时静态生成,Sanity 中发布新文章后需要触发重新验证(Revalidate),这正是 README 中"On-demand revalidation"一节的用途。实现位于 www/src/app/api/revalidate/route.ts,其流程如下:
- 从环境变量读取
SANITY_REVALIDATE_SECRET,缺失则返回 500; - 使用
next-sanity/webhook的parseBody解析请求体并校验签名,签名不合法返回 401; - 仅当
body._type === 'post'时执行revalidateTag('blog')与revalidatePath('/blog'),若载荷携带slug.current,再额外重新验证/blog/<slug>详情页; - 返回
{ revalidated: true, now: Date.now() }。
在 Sanity 中配置 Webhook
- 生成一个随机密钥,写入
.env与生产环境的SANITY_REVALIDATE_SECRET; - 在 Sanity Manage → API → Webhooks 创建 Webhook:
- URL:
https://corsair.dev/api/revalidate - Dataset:production
- Trigger on:Create、Update、Delete
- Filter:
_type == "post" - Secret:与
SANITY_REVALIDATE_SECRET相同的值
- URL:
这样编辑在 Sanity 中发布、更新或删除文章后,官网无需重新部署即可在请求时增量刷新对应页面,实现了"内容即改即生效"。
将 Markdown 种子文章迁移到 Sanity
仓库在 www/scripts/seed-data 中维护了 Markdown 格式的种子文章(如anthropic-introduces-claude-fable-5.md,带title、description、publishedAt、author的 frontmatter),迁移脚本 www/scripts/migrate-blog-to-sanity.ts 负责将其转换为 Portable Text 并写入 Sanity,转换流水线为:
- frontmatter 解析:用
gray-matter读取 Markdown 头部元数据; - 正文转 HTML:用
marked将 Markdown 编译为 HTML; - HTML 转 Block:用
@portabletext/block-tools的htmlToBlocks配合jsdom的 DOM 解析器,把 HTML 转换为符合postschema 的 Portable Text 块数组; - 图片上传:正则提取
alt形式的图片引用,将public/下对应文件通过client.assets.upload上传为 Sanity 资产,并生成带alt的 image block;文件缺失或上传失败时仅打印警告并跳过,提示后续可在 Studio 手动补充; - 文档写入:以
post-<slug>作为稳定的_id调用client.createOrReplace,实现幂等迁移(重复执行不会产生重复文档)。
脚本在启动时会校验NEXT_PUBLIC_SANITY_PROJECT_ID与SANITY_API_WRITE_TOKEN,缺失即退出并输出明确提示。
常用命令速查
README 与 www/package.json 共同定义了官网可用的脚本:
| 命令 | 说明 |
|---|---|
pnpm dev | 以 Turbopack 启动 Next.js 开发服务器 |
pnpm build/pnpm start | 生产构建与启动 |
pnpm sanity:schema | 部署 Schema 类型到 Sanity |
pnpm sanity:deploy | 部署托管 Studio 至corsair-www.sanity.studio |
pnpm blog:migrate | 将www/scripts/seed-data的 Markdown 种子文章导入 Sanity |
pnpm db:migrate/db:push/db:studio | Drizzle 数据库迁移与可视化操作 |
pnpm test | 运行src/**/*.test.ts下的 Node 测试 |
pnpm inngest:dev | 以http://localhost:3000/api/inngest为端点本地调试 Inngest 后台任务 |
实践要点与踩坑提示
- 写 Token 权限易混淆:迁移脚本的报错信息明确指出,
SANITY_API_WRITE_TOKEN必须是Editor 权限的 API Token,而非项目成员角色;把 Manager 成员角色误当作 API Token 类型是最常见的配置错误。 - CORS 必须双环境配齐:本地与生产各需要一个 CORS 来源,缺一不可,否则对应环境下 Studio 无法正常读写。
- 重新验证链路环环相扣:
SANITY_REVALIDATE_SECRET必须在 Sanity Webhook 的 Secret 与官网环境变量中保持一致,签名校验失败会直接返回 401。 - 空库有兜底 UI:博客首页在无文章时渲染"尚未发布"占位状态,新项目初始化后不会出现白屏。
至此,从 Sanity 项目初始化、post内容模型定义、GROQ 查询、Studio 嵌入,到 Webhook 按需重新验证与 Markdown 迁移,一套完整的 Next.js + Sanity 博客内容管线已全部打通。这套方案既适合 Corsair 这类需要"编辑自助发文 + 官网自动刷新"的开源项目,也可作为任意 Next.js 站点接入 Sanity CMS 的参考模板。
【免费下载链接】corsairConnect your users to their apps项目地址: https://gitcode.com/GitHub_Trending/corsa/corsair
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考