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.

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.
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.
Internal links should be references, not strings
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.
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:
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.


