Building with AI

Designing the Content Model by Conversation

I didn't hand-write the Sanity schema — I argued about it with an agent until the shapes were right. How a conversation became a typed content model with real constraints.

Val OvinnikovVal Ovinnikov3 min read
Translucent glowing blueprints of interlocking document cards — post, author, category, tag — arranged on a drafting table in warm amber light.

The schema is the part of a CMS you can't refactor away later without pain, so it's the part worth arguing about up front. I didn't hand-write this blog's Sanity schema in one sitting — I talked it through with an agent, pushing back until the shapes matched how the site actually renders. This post is how a conversation became a typed content model. It's part of the Building with AI series.

Start from what the page needs

The trap is designing a schema from nouns in your head. I started from the rendered page instead: a post card needs a title, an excerpt, a hero, a date, and an author. That framing kept the agent honest — every field had to earn its place by showing up on screen. A blog_post is one document with references out to the things that are shared across posts.

apps/cms/schema/blogPost.ts
1import { defineType, defineField } from "sanity";
2
3export const blogPost = defineType({
4  name: "blog_post",
5  type: "document",
6  fields: [
7    defineField({ name: "title", type: "string",
8      validation: (r) => r.required().max(120) }),
9    defineField({ name: "slug", type: "slug",
10      options: { source: "title" }, validation: (r) => r.required() }),
11    defineField({ name: "excerpt", type: "text",
12      validation: (r) => r.required().min(50).max(300) }),
13    defineField({ name: "author", type: "reference",
14      to: [{ type: "blog_author" }], validation: (r) => r.required() }),
15    defineField({ name: "body", type: "array",
16      of: [{ type: "block" }, { type: "code" }] }),
17  ],
18});

That highlighted excerpt line is a small decision with a long tail: it's the card summary, the meta description, and the RSS blurb all at once. Constraining it to 50–300 characters in the schema means the field can't silently become a second body — the constraint is the design.

Make the agent defend its shapes

The useful move was refusing to accept the first schema. When the agent suggested folding categories into a plain string, I asked what happens when I rename one across forty posts. A reference and a blog_category document, was the answer it argued its way to — and that's the right one, because a string can't be renamed once, only found-and-replaced everywhere.

Design by interrogation. If the agent can't defend a field against a realistic future edit, the field is wrong.

We ran the same test on tags (an array of references, capped at six), on the hero image (its own object so alt text can be required alongside the file), and on publishedAt (a real datetime that drives sort order, not a display string). Each answer left a constraint behind.

Required alt text is a schema decision, not a checklist

Accessibility survives when it's structural. Rather than trust myself to remember alt text, the hero is a small object type where the image and its alt are validated together — you can't save a hero without describing it:

apps/cms/schema/imageWithAlt.ts -->
1export const imageWithAlt = defineType({
2  name: "imageWithAlt",
3  type: "image",
4  fields: [
5    defineField({ name: "alt", type: "string",
6      validation: (r) => r.required() }),
7  ],
8});

The schema is the source of truth

Every shape we settled on is a contract the rest of the stack reads from, not a suggestion. That's the whole reason it was worth arguing over: the schema feeds sanity typegen, and those generated types flow into the query layer and out to React, so a bad shape here would surface as a type error three packages away. Turning that schema into typed queries is the next post, A Typed Service Layer with groqd; the full journey from schema to typed React is in From Sanity Schema to Typed React.

Argue about the schema now so the compiler can defend it later. The conversation cost an hour; a wrong content model would have cost a migration. Front-loading the disagreement is the same trick as front-loading the spec — cheap words that save expensive edits.

2 min

Rendering Portable Text in Next.js 16

Portable Text is just JSON until you render it. How I mapped Sanity's block format to React components — headings, links, and real code blocks — in the App Router.

building with ai
3 min

This Blog Was a Weekend Idea

How an idea over coffee became a production blog in 24 days and ~1,250 commits, built mostly by AI agents — with a map to every post that tells the story.

building with ai