Skip to content
brainNotFound

Engineering

Portable Text That Ranks: Structuring Sanity Content for Search

How to model rich article bodies in Sanity so the HTML that comes out is semantic, linkable and genuinely crawlable.

VoidReturn2 min read
Outlined blocks scattered over a dark grid, labelled 301 MOVED

Portable Text is the best thing about Sanity and the easiest thing to model badly. It gives you a structured document tree instead of a blob of HTML — but only if the schema you define actually distinguishes the things that matter to a search engine.

Here is the modelling I have settled on after a few dozen content sites.

Headings must be headings

The most common failure is a schema with a single normal style and a set of visual size options. It looks fine and produces a document with no outline at all.

Give editors h2, h3 and h4 as real block styles, and render them as real elements. Reserve h1 for the page template — an article has exactly one, and it is the title field, not something an editor can accidentally add twice.

sanity/schemas/objects/blockContent.tstypescript
styles: [
  { title: "Body", value: "normal" },
  { title: "Heading 2", value: "h2" },
  { title: "Heading 3", value: "h3" },
  { title: "Heading 4", value: "h4" },
  { title: "Quote", value: "blockquote" },
]

Once headings are structural, a table of contents falls out for free: walk the body array, pick out h2 and h3 blocks, slugify their text, and render anchors. No extra field, no editor effort, and it stays in sync forever.

If an editor types /blog/old-slug into a link field, that link is a time bomb. Model internal links as a reference annotation instead and resolve the URL at query time.

The projection that makes it workgroq
body[]{
  ...,
  markDefs[]{
    ...,
    _type == "internalLink" => {
      "slug": reference->slug.current,
      "docType": reference->_type
    }
  }
}

Now renaming a slug updates every link that points at it, and deleting a document surfaces as a broken reference in the Studio rather than a 404 in production.

Images need alt text at the schema level

Alt text that is optional is alt text that is empty. Make it a required field on the image object and the problem disappears at the source:

typescript
defineField({
  name: "alt",
  type: "string",
  validation: (rule) =>
    rule.required().min(4).error("Every image needs descriptive alt text."),
})

Project the asset's metadata.lqip and metadata.dimensions alongside it, and every image in the body renders with a blur placeholder and explicit width and height — which is most of your CLS budget defended in one query.

Model the blocks you actually publish

Callouts, code blocks with a language, comparison tables, embedded CTAs. If a pattern appears in three articles, it should be a block type rather than an editor hand-rolling markup. Each one is a small schema and a small renderer, and the payoff is that the HTML is predictable enough to style and to reason about.

  • Code block — language, optional filename, source. Render with a real <pre><code> and a language class.
  • Callout — tone, title, body. Render as an <aside>.
  • Table — rows plus a header flag. Render a real <table> with <th scope>.
  • CTA — heading, body, label, href. Keeps conversion copy inside the article where it converts.
A content model is an API contract with your future self. Every shortcut you take in the schema is a special case you will render forever.

The excerpt does double duty

One field, capped at 160 characters, used on cards and as the meta description fallback. Validating the cap in the schema is more effective than any amount of documentation — see SEO in the Next.js App Router for how the fallback chain is wired.

// related

Keep reading.

All Engineering