Post ·
I first wrote about using Sanity for this blog in 2021. My goals then were simple: writing should be enjoyable, content should live apart from the code so a typo fix doesn't need a deploy, and everything should be typed. Those goals still hold. Almost everything about how I meet them has changed.
The old setup stored MDX in a Sanity Markdown field and compiled it on the server. Dropping a React component into the middle of a post felt like magic. It also meant my content was code. A missing closing tag broke the page, and nothing in the CMS could catch it before I published.
This time the post body is Portable Text: an array of typed blocks stored as JSON. A code block in the Studio is saved as plain data:
{
"_type": "code",
"language": "typescript",
"filename": "sanity/live.ts",
"code": "export const { SanityLive, sanityFetch } = defineLive({ client, strict: true });"
}The site maps each block type to a React component. Here's what I gained and gave up:
There are four document types: post, tag, company and role. A post has a kind field, either a normal post or a portfolio article. A portfolio article also has the one-sentence accomplishment shown on my home page, and the field only appears when it's needed:
defineField({
name: 'accomplishment',
type: 'text',
rows: 3,
group: 'portfolio',
description: 'The line shown under the role on the home page. It links to this article.',
hidden: ({document}) => !isPortfolioArticle(document),
validation: (rule) =>
rule.custom((value, {document}) =>
isPortfolioArticle(document) && !value
? 'Portfolio articles need an accomplishment'
: true,
),
}),A company holds an ordered list of roles, and each role holds an ordered list of portfolio articles. The Portfolio section of the home page is one GROQ query that follows those references, and each accomplishment links to its full article. Reordering my work history happens with drag and drop in the Studio, not in a pull request.
Tags are documents too, each with a description, links to the project's homepage, repo and docs, and an icon. Every tag gets its own page listing the posts that use it.
Queries are wrapped in defineQuery, and Sanity TypeGen reads both the schema and the queries to generate a type for each query's result. The Studio repo generates the types straight into the site repo:
typegen: {
path: '../my-website/{app,components,lib,sanity}/**/*.{ts,tsx}',
schema: 'schema.json',
generates: '../my-website/sanity/types.ts',
},After a schema change I run one script, and any component that reads a renamed or removed field fails to type-check:
npm run typegenThe site runs Next.js 16 with Cache Components, and next-sanity's defineLive connects the two. Strict mode makes every fetch state its perspective (published or drafts) and whether to add stega, the hidden markers that power Visual Editing. That way nothing quietly falls back to the wrong one.
export const { SanityLive, sanityFetch } = defineLive({
client,
serverToken: token,
browserToken: token,
strict: true,
});
// The app's one shared 'use cache' boundary.
export const cachedSanity: StrictDefinedFetchType = async (options) => {
'use cache';
return sanityFetch(options);
};Pages check for draft mode once, then pass perspective and stega down to the components that fetch. Visitors get published content from the static shell. I get drafts with click-to-edit markers, from the same components.
The 2021 post called Sanity's preview the best of any headless CMS because changes showed up as I typed. That's still true, and the Presentation Tool goes further. The Studio shows the real site beside the editor, and clicking any text on the page jumps to the field it came from.
Setting it up takes a few lines in the Studio config:
presentationTool({
resolve,
previewUrl: {
initial: process.env.SANITY_STUDIO_PREVIEW_URL || 'http://localhost:3000',
previewMode: {enable: '/api/draft-mode/enable', disable: '/api/draft-mode/disable'},
},
}),On the site, it takes one route handler that checks the Studio's preview secret and turns on draft mode:
import { client } from '@/sanity/client';
import { defineEnableDraftMode } from 'next-sanity/draft-mode';
export const { GET } = defineEnableDraftMode({
client: client.withConfig({ token: process.env.SANITY_API_READ_TOKEN }),
});The resolve option maps routes to documents both ways. Opening /blog/some-post in the preview selects that post. Opening a post in the editor lists every page it appears on, including the home page for portfolio articles.
Sanity has leaned into AI tooling, and it paid off on this project. The Agent Toolkit gives coding agents skills for schema design, GROQ and framework integrations, and the MCP server lets them query and change content directly. While I built this site with Claude Code, it read the dataset to check its own work and seeded the tags and the first drafts of my portfolio articles with scripts. I then review and edit everything in the Studio.
strict in defineLive. Being explicit about perspective prevents a whole class of preview bugs.stega: false, and run stegaClean() on any Sanity string before comparing it.Next, I'll walk through how this blog's code blocks work in Syntax Highlighting with sugar-high.