DEV Community

Zach Scroggins
Zach Scroggins

Posted on Originally published at zachscroggins.com on

Using Sanity to Manage Blog Content

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.

Portable Text instead of MDX

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 });"
}
Enter fullscreen mode Exit fullscreen mode

The site maps each block type to a React component. Here's what I gained and gave up:

  • No inline JSX. For a callout, I add a callout type to the schema and a component that renders it. That's a bit more work once, and none after that.
  • Validation before publishing. Images need alt text. Portfolio articles need their accomplishment line. The Studio won't publish a document that breaks a rule.
  • One source, many outputs. The same body renders on the page, in the RSS feed and anywhere else, without parsing or executing anything.

One content model for the blog and the portfolio

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,
    ),
}),
Enter fullscreen mode Exit fullscreen mode

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.

Typed queries with TypeGen

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',
},
Enter fullscreen mode Exit fullscreen mode

After a schema change I run one script, and any component that reads a renamed or removed field fails to type-check:

npm run typegen
Enter fullscreen mode Exit fullscreen mode

Fetching content with Cache Components

The 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);
};
Enter fullscreen mode Exit fullscreen mode

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.

Live preview with the Presentation Tool

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'},
  },
}),
Enter fullscreen mode Exit fullscreen mode

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 }),
});
Enter fullscreen mode Exit fullscreen mode

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 and AI agents

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.

If you're starting today

  1. Model the content, not the page. Tags, companies and roles as documents made both the blog and the portfolio easy to build.
  2. Turn on strict in defineLive. Being explicit about perspective prevents a whole class of preview bugs.
  3. Keep stega out of metadata, sitemaps and feeds with stega: false, and run stegaClean() on any Sanity string before comparing it.
  4. Generate types, and regenerate them every time the schema changes.

Next, I'll walk through how this blog's code blocks work in Syntax Highlighting with sugar-high.

Top comments (0)