Next.js + Sanity 静态博客示例全解:从环境配置、TypeGen 到 Draft Mode 与增量再验证
本文基于 Next.js 官方仓库中的 cms-sanity 示例 展开,介绍如何用 App Router 构建一个由 Sanity 提供内容源的静态博客:包括如何用 create-next-app 拉取脚手架、配置 NEXT_PUBLIC_SANITY_PROJECT_ID 等环境变量、用 Sanity CLI 初始化项目与数据集、创建只读 API Token,以及本地开发、内容录入和生产部署的完整流程。读完本文,你既能按步骤跑通这个示例,也能看懂其源码中 sanityFetch 的视角(perspective)切换、60 秒 Time-based Revalidation、Draft Mode 与 Stega 可视化编辑的底层实现机制。
这个示例做了什么
cms-sanity 是一个静态生成的博客示例,前端使用 Next.js App Router,内容管理交给 Sanity。它内置了一个原生挂载在 yourblog.com/studio 路由下的 Sanity Studio,支持实时协作编辑、细粒度的修订历史,以及基于 Presentation 工具的可视化编辑与即时预览。Studio 连接的是 Sanity Content Lake,提供托管的内容 API、灵活的 GROQ 查询语言、按需的图片变换与 patch 能力。
根据 README,该示例的核心特性包括:
- 一个高性能的静态博客,支持编辑文章(posts)、作者(authors)和站点设置(site settings);
- TypeScript 配置,并启用 Sanity TypeGen,可为 GROQ 查询生成类型;
- 可定制的原生创作环境(Sanity Studio),挂载在
yourblog.com/studio; - 实时、协作式的内容编辑,带细粒度修订历史;
- 全站点可用的并排即时内容预览;
- 支持 block content 以及高度可定制的字段;
- 增量静态再验证(Incremental Static Revalidation),发布新内容无需等待整站重建;
- 集成 Unsplash,方便图片素材管理;
- 预配置的 Sanity AI Assist,用于自动生成图片 alt 文本;
- 开箱即用的 Vercel Visual Editing 支持。
官方同时提供了一个在线演示站点(next-blog.sanity.build),可直接访问体验完整效果。
项目结构与关键文件
在展开配置步骤之前,先给出本示例的目录脉络,便于后文对照源码:
| 路径 | 作用 |
|---|---|
| examples/cms-sanity/sanity.config.ts | Sanity Studio 客户端配置:schema、Presentation、Structure、Unsplash、AI Assist 等插件 |
| examples/cms-sanity/sanity/lib/api.ts | 集中读取环境变量,导出 dataset、projectId、apiVersion、studioUrl |
| examples/cms-sanity/sanity/lib/client.ts | 基于 next-sanity 创建全局查询客户端,配置 CDN、视角与 Stega |
| examples/cms-sanity/sanity/lib/fetch.ts | sanityFetch 封装:在 published 与 previewDrafts 两个视角间自动切换 |
| examples/cms-sanity/sanity/lib/queries.ts | 全部 GROQ 查询(settings、hero、moreStories、post) |
| examples/cms-sanity/sanity/lib/token.ts | 读取 SANITY_API_READ_TOKEN,缺失即抛错(server-only) |
| examples/cms-sanity/sanity/lib/utils.ts | urlForImage 图片 URL 构造、OpenGraph 图解析、文档类型到 href 的映射 |
| examples/cms-sanity/sanity/schemas/ | 内容 schema:documents/post.ts、documents/author.ts、singletons/settings.tsx |
| examples/cms-sanity/sanity/plugins/assist.ts | AI Assist 预设提示词插件 |
| examples/cms-sanity/app/(sanity)/studio/[[...tool]]/page.tsx | Studio 挂载点(App Router 动态路由) |
| examples/cms-sanity/app/api/draft-mode/enable/route.ts | Draft Mode 启用接口,Presentation 预览时调用 |
| examples/cms-sanity/app/(blog)/page.tsx/page.tsx) | 博客首页,sanityFetch + 演示数据回退 |
| examples/cms-sanity/sanity.types.ts | TypeGen 生成的查询结果类型 |
| examples/cms-sanity/.env.local.example | 环境变量模板 |
快速开始:用 create-next-app 拉取脚手架
使用 create-next-app 配合 npm、Yarn 或 pnpm 任一包管理器初始化示例:
npx create-next-app --example cms-sanity next-sanity-blog
yarn create next-app --example cms-sanity next-sanity-blog
pnpm create next-app --example cms-sanity next-sanity-blog
每当修改了 GROQ 查询后,需要重新生成 TypeScript 类型:
npm run typegen
对照 package.json 可以看到这些脚本的实际定义:
{
"scripts": {
"predev": "npm run typegen",
"dev": "next --turbo",
"prebuild": "npm run typegen",
"build": "next build",
"setup": "npx sanity@latest init --env .env.local",
"typegen": "sanity schema extract && sanity typegen generate"
}
}
也就是说:
npm run dev会在启动前自动触发predev→typegen,先执行sanity schema extract把 schema 抽取为 schema.json,再执行sanity typegen generate生成类型;npm run build同理会在构建前重新生成类型,保证 sanity.types.ts 中的查询结果类型与 sanity/lib/queries.ts 中的查询字符串始终一致;- 示例使用
next --turbo(Turbopack)作为开发服务器。
配置全流程
Step 1. 设置环境变量
方式一:复用远程环境变量
如果你已经通过 Vercel 的 Deploy Button 部署过项目,可以直接把 Vercel 项目的环境变量拉到本地,跳过手动配置:
npx vercel link
npx vercel env pull
方式二:使用 Sanity CLI 初始化
把 .env.local.example 复制为 .env.local:
cp -i .env.local.example .env.local
.env.local.example 的内容就是三个待填变量:
NEXT_PUBLIC_SANITY_PROJECT_ID=
NEXT_PUBLIC_SANITY_DATASET=
SANITY_API_READ_TOKEN=
然后运行 setup 命令,由 Sanity CLI 引导你创建/选择项目与数据集,并自动写回对应的环境变量:
npm run setup
yarn setup
pnpm run setup
(即执行 npx sanity@latest init --env .env.local,见 package.json。)
过程会连续提问,README 给出的示例输出大致如下:
Need to install the following packages:
sanity@3.30.1
Ok to proceed? (y) y
You're setting up a new project!
We'll make sure you have an account with Sanity.io.
Press ctrl + C at any time to quit.
Prefer web interfaces to terminals?
You can also set up best practice Sanity projects with
your favorite frontends on https://www.sanity.io/templates
Looks like you already have a Sanity-account. Sweet!
✔ Fetching existing projects
? Select project to use Templates [r0z1eifg]
? Select dataset to use blog-vercel
? Would you like to add configuration files for a Sanity project in this Next.js folder? No
Detected framework Next.js, using prefix 'NEXT_PUBLIC_'
Found existing NEXT_PUBLIC_SANITY_PROJECT_ID, replacing value.
Found existing NEXT_PUBLIC_SANITY_DATASET, replacing value.
关键注意点:当 CLI 询问 Would you like to add configuration files for a Sanity project in this Next.js folder? 时,必须回答 No——因为本示例已经内置了所需的 Studio 配置文件(sanity.config.ts、sanity.cli.ts 等),再覆盖会破坏现有结构。
创建只读 Token(Read Token)
完成上一步后,.env.local 中应已有 NEXT_PUBLIC_SANITY_PROJECT_ID 与 NEXT_PUBLIC_SANITY_DATASET 的值。在运行项目前,还需要配置一个只读 token(SANITY_API_READ_TOKEN)——它在 Sanity Studio 对你的应用做 live preview(预览草稿内容)时用于鉴权。创建步骤:
- 登录 Sanity 控制台(manage.sanity.io)并选择你的项目;
- 点击
🔌 API标签页; - 点击
+ Add API token; - 命名为 "next blog live preview read token",
Permissions选择Viewer,点击Save; - 复制 token,追加到
.env.local:
SANITY_API_READ_TOKEN="<paste your token here>"
最终 .env.local 形如:
NEXT_PUBLIC_SANITY_PROJECT_ID="r0z1eifg"
NEXT_PUBLIC_SANITY_DATASET="blog-vercel"
SANITY_API_READ_TOKEN="sk..."
安全提示:务必把
.env.local加入.gitignore(本示例仓库的 .gitignore 已包含该项),避免 token 被误提交。
从源码可以印证 token 的强制性:sanity/lib/token.ts 顶部 import "server-only" 确保它只会在服务端被打包,并在变量缺失时直接 throw new Error("Missing SANITY_API_READ_TOKEN")——即没有 token,项目根本无法启动。
Step 2. 本地开发模式运行
npm install && npm run dev
yarn install && yarn dev
pnpm install && pnpm dev
博客应运行在 http://localhost:3000,Sanity Studio 运行在 http://localhost:3000/studio。Studio 之所以挂在 /studio,是因为 sanity/lib/api.ts 中定义了 studioUrl = "/studio",并被 sanity.config.ts 用作 basePath;而挂载它的实际路由是 App Router 的 app/(sanity)/studio/[[...tool]]/page.tsx。
Step 3. 录入内容
打开 http://localhost:3000/studio 中的 Sanity Studio。默认进入的是 Presentation 工具:左侧是博客的实时预览,右侧是文档列表。按 README 的引导完成首次内容创作:
- 点击左上角 "+ Create",选择 Post;
- 为 Title 输入内容;
- 点击 Generate 生成 Slug——生成后该文章就会出现在左侧预览中;
- 在 Content 中填写正文,也可以点击 AI Assist 的 ✨ 按钮,基于标题生成草稿(Generate sample content);
- 在 Excerpt 中填写摘要(同样可由 AI Assist 生成);
- 从 Unsplash 资产源中选择 Cover Image(在 Select 下拉框中选择 Unsplash),点击 "Crop image" 调整热点与裁剪,左侧可实时预览,右侧查看其他变换格式;
- 顶部点击 "Structure",再在左侧选择 "Settings",创建 Settings 单例文档,即可自定义博客名称、描述等站点级配置。
重要:每篇 post 记录在保存之后还需要点击 Publish,才会在 Draft Mode 之外可见。生产环境新内容走 Time-based Revalidation,最多可能需要 1 分钟才能看到变更;由于采用 stale-while-revalidate 模式,你可能需要刷新几次才能看到最新内容。
如果数据集还是空的,博客首页不会报错——app/(blog)/page.tsx/page.tsx) 会回退到 sanity/lib/demo.ts 中的演示标题、描述与示例文章,保证空库时页面依然完整可看。
Step 4. 部署到生产
如果你之前已经用 Deploy Button 部署过,可以跳过此步。
部署本地项目的方式:先把代码推送到 GitHub/GitLab/Bitbucket 等仓库,再在 Vercel 中导入项目。
注意:在 Vercel 导入项目时,务必进入 Environment Variables 面板,把变量设置为与本地
.env.local一致(NEXT_PUBLIC_SANITY_PROJECT_ID、NEXT_PUBLIC_SANITY_DATASET、SANITY_API_READ_TOKEN)。
部署完成后,把本地代码与 Vercel 项目关联:
npx vercel link
提示:生产环境中可以点击顶部的 "Back to published" 退出 Draft Mode;在 Vercel 的 Preview Deployments 中,可以通过 Vercel Toolbar 切换 Draft Mode 以查看未发布内容。
源码剖析:数据流与再验证机制
上面的步骤是"怎么用",下面看"为什么这样设计"。
双视角(Perspective)的 sanityFetch 封装
整个示例的数据获取都经过 sanity/lib/fetch.ts 中的 sanityFetch。它的核心逻辑是:
const perspective =
_perspective || (await draftMode()).isEnabled
? "previewDrafts"
: "published";
- 未显式指定视角时,读取 Next.js 的
draftMode()状态:Draft Mode 开启则用previewDrafts(草稿视角),否则用published(已发布视角); published视角:走 Sanity API CDN(useCdn: true),并设置next: { revalidate: 60 }的 Time-based Revalidation——60 秒这个值是对齐 Sanity API CDN 的 TTL(源码注释明确说明 "set to match the time-to-live on Sanity's API CDN (60 seconds)")。这正对应了 README 中"生产环境新内容最多 1 分钟生效"的说明;previewDrafts视角:该视角在 API CDN 上不可用,因此useCdn: false直连 Live API,同时必须携带只读 token(token,来自 sanity/lib/token.ts)才能拉取未发布草稿,并且revalidate: 0完全禁用缓存——因为一旦缓存,Studio 里的 live preview 就无法实时反映编辑内容。
基础客户端在 sanity/lib/client.ts 中创建,默认 useCdn: true、perspective: "published",并配置了 stega(用于可视化编辑的 Content Source Maps 嵌入),其 filter 对 title 字段强制嵌入。
Draft Mode 的启用链路
Presentation 工具打开预览时,会请求本站的 Draft Mode 启用接口。app/api/draft-mode/enable/route.ts 只有一行有效代码:
export const { GET } = defineEnableDraftMode({
client: client.withConfig({ token }),
});
它在 next-sanity/draft-mode 的帮助下,校验来自 Sanity Studio 的请求(携带 SANITY_API_READ_TOKEN 鉴权)并设置 Draft Mode cookie。与之呼应的是 sanity.config.ts 中 Presentation 的配置:
previewUrl: { previewMode: { enable: "/api/draft-mode/enable" } }
于是形成闭环:Studio 预览 → 调用 /api/draft-mode/enable → 站点进入 Draft Mode → sanityFetch 自动切到 previewDrafts → 编辑器左侧实时看到未发布草稿。
GROQ 查询与类型生成
sanity/lib/queries.ts 中所有查询都用 next-sanity 的 defineQuery 定义,并通过一个共享的 postFields 片段保持字段一致:
const postFields = /* groq */ `
_id,
"status": select(_originalId in path("drafts.**") => "draft", "published"),
"title": coalesce(title, "Untitled"),
"slug": slug.current,
excerpt,
coverImage,
"date": coalesce(date, _updatedAt),
"author": author->{"name": coalesce(name, "Anonymous"), picture},
`;
export const settingsQuery = defineQuery(`*[_type == "settings"][0]`);
export const heroQuery = defineQuery(`
*[_type == "post" && defined(slug.current)] | order(date desc, _updatedAt desc) [0] { content, ${postFields} }
`);
export const moreStoriesQuery = defineQuery(`
*[_type == "post" && _id != $skip && defined(slug.current)] | order(date desc, _updatedAt desc) [0...$limit] { ${postFields} }
`);
export const postQuery = defineQuery(`
*[_type == "post" && slug.current == $slug] [0] { content, ${postFields} }
`);
几个值得注意的写法:coalesce(title, "Untitled") 让无标题的草稿也有兜底显示;select(_originalId in path("drafts.**") => "draft", "published") 判断文档处于草稿集还是已发布集,从而渲染不同的状态标签;moreStoriesQuery 用 $skip/limit 参数配合首页 hero 文章之后的列表。TypeGen 会为这些查询字符串生成对应的结果类型(如 HeroQueryResult),页面组件里 import type { HeroQueryResult } from "@/sanity.types" 即是消费方——这也是"改完 GROQ 必须跑 npm run typegen"的原因。
Schema、图片 URL 与 Studio 配置
- Schema:
post、author是常规文档(sanity/schemas/documents/),settings是单例文档(sanity/schemas/singletons/settings.tsx)。sanity.config.ts 通过singletonPlugin([settings.name])让全局 "New" 按钮与文档操作适配单例语义,并用structureTool定制左侧文档树结构。 - 图片 URL:sanity/lib/utils.ts 用
@sanity/image-url构造按需变换的图片 URL(auto("format").fit("max")),resolveOpenGraphImage固定输出 1200×627 的 OG 图,供文章页generateMetadata使用。 - Presentation 定位文档位置:
presentationTool的resolve.locations里,post文档通过select+resolve解析出自身页面(resolveHref("post", slug)映射为/posts/${slug},见 utils)以及首页,因此编辑文章时 Studio 会同时指出"这篇文章出现在首页和详情页"。 - 开发专属插件:
visionTool(用 GROQ 在 Studio 内直接查库)只在process.env.NODE_ENV === "development"时启用。 - AI Assist:sanity/plugins/assist.ts 通过
assistWithPresets()注册预设提示词,对应 README 中的"生成正文、生成摘要、生成图片 alt"能力。
环境变量读取的防御式设计
sanity/lib/api.ts 用一个 assertValue 助手集中校验环境变量:缺少 NEXT_PUBLIC_SANITY_DATASET 或 NEXT_PUBLIC_SANITY_PROJECT_ID 会立即抛出带明确信息的错误,而不是让错误在深层查询时才暴露。apiVersion 则可通过 NEXT_PUBLIC_SANITY_API_VERSION 覆盖,默认值为 2024-02-28。
后续与相关示例
本地跑通并部署后,README 建议加入 Sanity 社区继续跟进新能力。本仓库中还有大量同结构的 CMS 集成示例可供对比参考,例如 cms-contentful、cms-wordpress、cms-ghost、cms-payload、cms-prismic、cms-storyblok 等,以及 blog-starter 博客模板。
总结来说,这个示例的价值在于:它展示了一套完整的"静态站点 + 无头 CMS"生产级组合——用 TypeGen 保证查询类型安全,用 60 秒 Time-based Revalidation 平衡静态性能与内容新鲜度,用 Draft Mode + previewDrafts 视角实现发布前所见即所得,用 Stega 内容源映射打通 Sanity Presentation 与 Vercel Visual Editing 两条可视化编辑通路。
atomcodeClaude Code 的开源替代方案。连接任意大模型,编辑代码,运行命令,自动验证 — 全自动执行。用 Rust 构建,极致性能。 | An open-source alternative to Claude Code. Connect any LLM, edit code, run commands, and verify changes — autonomously. Built in Rust for speed. Get StartedRust0624
Hy4-previewHy4 preview 是由腾讯混元团队研发的新一代混合专家(MoE)旗舰模型。模型总参数量 770B,每个 token 激活 49B,主干共包含78层,第一层采用标准 FFN,其余 77 层均为 MoE 结构,每层包含 256 个路由专家与 1 个共享专家,每个 token 激活 top-8 路由专家及共享专家。主干之外原生内置 1 层 MTP(总参数量 10B,激活 0.7B)以支持投机解码。Python00
GLM-5.3GLM-5.3 与 GLM-5.2 使用相同的基座模型——所有提升均来自后训练。与 GLM-5.2 相比,它在复杂编程和长程任务上的表现显著提升。Jinja00
GLM-5.3-FlashGLM-5.3-Flash (320B-A18B),是GLM-5系列的首个原生多模态模型。320B总参数,能力超过GLM-5.2Jinja00
Spark-X2.5-4BSpark-X2.5-4B 旨在让强大的 AI 更实用、更高效、更易获得。在广泛日常任务中表现强劲,涵盖对话、写作、翻译、推理、编码、工具调用以及智能体工作流,并在同等规模的开源模型中取得领先成绩。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00
Spark-X2.5-1.7BSpark-X2.5-1.7B 旨在让强大的 AI 更加实用、高效且易于获取。这些模型在广泛的日常任务中表现出色,涵盖对话、写作、翻译、推理、编程、工具调用和智能体工作流,并在同等规模的开源模型中取得领先结果。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00