首页
/ Next.js + Sanity 静态博客示例全解:从环境配置、TypeGen 到 Draft Mode 与增量再验证

Next.js + Sanity 静态博客示例全解:从环境配置、TypeGen 到 Draft Mode 与增量再验证

2026-09-06 16:09:07作者:卓艾滢Kingsley

本文基于 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 集中读取环境变量,导出 datasetprojectIdapiVersionstudioUrl
examples/cms-sanity/sanity/lib/client.ts 基于 next-sanity 创建全局查询客户端,配置 CDN、视角与 Stega
examples/cms-sanity/sanity/lib/fetch.ts sanityFetch 封装:在 publishedpreviewDrafts 两个视角间自动切换
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.tsdocuments/author.tssingletons/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 会在启动前自动触发 predevtypegen,先执行 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.tssanity.cli.ts 等),再覆盖会破坏现有结构。

创建只读 Token(Read Token)

完成上一步后,.env.local 中应已有 NEXT_PUBLIC_SANITY_PROJECT_IDNEXT_PUBLIC_SANITY_DATASET 的值。在运行项目前,还需要配置一个只读 token(SANITY_API_READ_TOKEN)——它在 Sanity Studio 对你的应用做 live preview(预览草稿内容)时用于鉴权。创建步骤:

  1. 登录 Sanity 控制台(manage.sanity.io)并选择你的项目;
  2. 点击 🔌 API 标签页;
  3. 点击 + Add API token
  4. 命名为 "next blog live preview read token",Permissions 选择 Viewer,点击 Save
  5. 复制 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_IDNEXT_PUBLIC_SANITY_DATASETSANITY_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: trueperspective: "published",并配置了 stega(用于可视化编辑的 Content Source Maps 嵌入),其 filtertitle 字段强制嵌入。

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-sanitydefineQuery 定义,并通过一个共享的 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 配置

  • Schemapostauthor 是常规文档(sanity/schemas/documents/),settings 是单例文档(sanity/schemas/singletons/settings.tsx)。sanity.config.ts 通过 singletonPlugin([settings.name]) 让全局 "New" 按钮与文档操作适配单例语义,并用 structureTool 定制左侧文档树结构。
  • 图片 URLsanity/lib/utils.ts@sanity/image-url 构造按需变换的图片 URL(auto("format").fit("max")),resolveOpenGraphImage 固定输出 1200×627 的 OG 图,供文章页 generateMetadata 使用。
  • Presentation 定位文档位置presentationToolresolve.locations 里,post 文档通过 select + resolve 解析出自身页面(resolveHref("post", slug) 映射为 /posts/${slug},见 utils)以及首页,因此编辑文章时 Studio 会同时指出"这篇文章出现在首页和详情页"。
  • 开发专属插件visionTool(用 GROQ 在 Studio 内直接查库)只在 process.env.NODE_ENV === "development" 时启用。
  • AI Assistsanity/plugins/assist.ts 通过 assistWithPresets() 注册预设提示词,对应 README 中的"生成正文、生成摘要、生成图片 alt"能力。

环境变量读取的防御式设计

sanity/lib/api.ts 用一个 assertValue 助手集中校验环境变量:缺少 NEXT_PUBLIC_SANITY_DATASETNEXT_PUBLIC_SANITY_PROJECT_ID 会立即抛出带明确信息的错误,而不是让错误在深层查询时才暴露。apiVersion 则可通过 NEXT_PUBLIC_SANITY_API_VERSION 覆盖,默认值为 2024-02-28

后续与相关示例

本地跑通并部署后,README 建议加入 Sanity 社区继续跟进新能力。本仓库中还有大量同结构的 CMS 集成示例可供对比参考,例如 cms-contentfulcms-wordpresscms-ghostcms-payloadcms-prismiccms-storyblok 等,以及 blog-starter 博客模板。

总结来说,这个示例的价值在于:它展示了一套完整的"静态站点 + 无头 CMS"生产级组合——用 TypeGen 保证查询类型安全,用 60 秒 Time-based Revalidation 平衡静态性能与内容新鲜度,用 Draft Mode + previewDrafts 视角实现发布前所见即所得,用 Stega 内容源映射打通 Sanity Presentation 与 Vercel Visual Editing 两条可视化编辑通路。

登录后查看全文
热门项目推荐
相关项目推荐