Portable Text is a good format for the reason that makes it awkward to render: it is a flat array of blocks with no nesting. That means it survives being moved between systems, queried, diffed and stored — and it means the structure your HTML needs is not in the data.
The clearest example is lists. Portable Text stores each bullet as its own top-level block with a listItem property. Render the array naively and you get five separate <ul> elements each containing one <li>, which looks almost right and is wrong in every way that matters.
This site renders its blog body with about eighty lines of Astro rather than a library. Here is what those lines have to handle and why writing them was the right call.
What does the format actually give you?#
An array of blocks. Each is either a text block with children and marks, or a custom type you defined in the schema.
[
{ "_type": "block", "style": "h2", "children": [{ "_type": "span", "text": "Why lists break" }] },
{ "_type": "block", "style": "normal", "listItem": "bullet",
"children": [{ "_type": "span", "text": "First point" }] },
{ "_type": "block", "style": "normal", "listItem": "bullet",
"children": [{ "_type": "span", "text": "Second point" }] },
{ "_type": "image", "src": "/blog/x/diagram.avif", "alt": "…" }
] Two bullets, two sibling blocks, no container. Nothing in the data says "these belong to one list" beyond the fact that they are adjacent and share a listItem value — so grouping them is the renderer's job.
Marks are indirect on purpose#
The indirection is the part that most people find surprising the first time they sit down and read through the format.
A span's marks array holds strings. Some are literal decorators like strong; others are keys pointing into the block's markDefs array, which is where a link's href lives. That indirection is what lets a link carry arbitrary data without inflating every span.
{
"markDefs": [{ "_key": "a1", "_type": "link", "href": "/services" }],
"children": [
{ "_type": "span", "text": "See the ", "marks": [] },
{ "_type": "span", "text": "services", "marks": ["a1", "strong"] }
]
} So rendering a span means resolving each mark against markDefs first, and treating an unresolved one as a decorator. A mark that matches neither should be ignored rather than crashing the page.
How do you coalesce lists?#
Walk the array, and when you hit a listItem block, consume every following block with the same listItem value into one container.
const out = [];
for (let i = 0; i < blocks.length; i++) {
const b = blocks[i];
if (!b.listItem) { out.push(render(b)); continue; }
// Consume the whole run of same-type items into one list.
const kind = b.listItem;
const items = [];
while (i < blocks.length && blocks[i].listItem === kind) items.push(blocks[i++]);
i--; // step back; the for loop increments
out.push(list(kind, items));
} The i-- is the kind of detail that produces an off-by-one that only shows up when a list is immediately followed by a heading. Worth a test on its own.
Match on the list type, not just presence#
A bulleted list followed immediately by a numbered one is two runs. Consuming on "has listItem" merges them into a single <ul> and silently loses the numbering — a bug that only appears when an author happens to put the two adjacent.
It is worth writing the test for this case specifically rather than trusting a visual check, because the broken version renders almost identically. Five one-item lists and one five-item list look the same until you inspect the markup or apply a style to the container — at which point the spacing between items is wrong in a way nobody can attribute to anything.
Nesting comes from level, not structure#
Portable Text expresses indentation with a numeric level, still flat. Supporting nested lists means tracking level transitions and opening or closing containers as it changes. If your Studio does not allow nesting, do not build this — but make sure the schema actually forbids it rather than allowing something you silently flatten.
Why write it yourself rather than use a library?#
Because the renderer is where the design system meets the content, and that is exactly the boundary you want to own.
| Library | Your own | |
|---|---|---|
| Time to first render | Minutes | An afternoon |
| Handles every block type | Yes, generically | Only what you ship |
| Framework fit | Depends on the package | Native by definition |
| Adding a custom type | Config plus a component | A branch in one file |
| Ownership when it breaks | Issue tracker | Yours |
Eighty lines is not a serious maintenance burden, and the payoff is that adding a table block, a callout or a code block is one branch in a file you already understand — rather than working out how a package wants custom serializers configured.
The honest counterweight: on a large content model with a dozen custom types, a library's conventions start earning their keep. The line is roughly whether the renderer stays readable in one screen. Past that, use the library.
There is also a portability argument that cuts against writing your own, and it is worth being honest about. A library maintained across frameworks means the same content renders identically in a Next.js app, an email template and a native client. If the content will only ever be read on one site, that guarantee is worth nothing — if it might not, it is worth quite a lot.
Restrict the schema to what you render#
The safest version of a hand-written renderer is one whose input cannot contain surprises. If the Studio block schema allows exactly the styles and marks you handle, there is no unknown case — an editor cannot produce an h5 you forgot, because the interface does not offer one.
What must never crash the page?#
Anything unexpected. A CMS is edited by people, and a renderer that throws takes the whole route down at build time.
- An unknown block type — skip it silently rather than rendering an error.
- A mark with no matching def — treat it as a decorator, ignore it if unrecognised.
- An empty block — filter it, or you get stray empty paragraphs where somebody hit return.
- A missing image file — render no figure rather than a broken image.
- An unknown code language — fall back to plain text rather than failing to highlight.
Each of those is one line of defensiveness and each prevents a build failure from a content edit. The asymmetry is stark: the cost of tolerance is a slightly wrong-looking block, and the cost of strictness is a page that does not exist.
Empty blocks are more common than you expect#
Editors press return to make space, which produces a block with a single empty span. Rendering those gives a document with random gaps that no CSS rule explains. A check for any non-whitespace text across the children handles it.
How do headings and the contents list stay in sync?#
By having one function decide the ids, used by both.
This is the failure that is invisible until somebody clicks. The renderer slugifies a heading one way, the contents list slugifies it another, and the two agree on every heading except the ones with a colon in them — so most links work and a few scroll nowhere.
// One place decides ids. The renderer stamps them; the TOC links to them.
export function headings(blocks) {
const seen = new Map();
return blocks
.filter((b) => b._type === 'block' && (b.style === 'h2' || b.style === 'h3'))
.map((b) => {
const base = slug(text(b));
const n = seen.get(base) ?? 0;
seen.set(base, n + 1);
return { id: n ? `${base}-${n + 1}` : base, text: text(b), level: … };
});
} The duplicate-suffix logic is why sharing matters. Two sections called "Conclusion" need distinct ids, and any suffixing scheme implemented twice will eventually disagree — leaving the contents list pointing at a fragment that does not exist.
The slugifier itself deserves care too. Strip punctuation, collapse whitespace, lowercase, and decide explicitly what happens to non-Latin characters — a heading in another script that slugifies to an empty string produces an id of -2 on the second occurrence and nothing on the first, which is a link to the top of the page.
Derive, do not store#
Storing heading ids in the content means they go stale the moment somebody edits a heading. Deriving them at render time from the text keeps them correct by construction, at the cost of an id changing when a heading is reworded — which is the right trade for an internal contents list and the wrong one for URLs you have published externally.
How do you handle code blocks?#
Highlight at build time, and degrade rather than fail on an unknown language.
A build-time highlighter ships coloured HTML and no client JavaScript, which is strictly better than a runtime library for content that never changes after the build. Astro bundles Shiki, so this costs no new dependency at all.
---
import { Code } from 'astro:components';
---
<Code code={block.code} lang={block.language ?? 'plaintext'} /> The fallback matters because language is a free-text field an editor fills in. Somebody will type node or leave it blank, and the correct response is unhighlighted code rather than a build error.
One detail that repays a minute of thought: the highlighter emits its own <pre> with its own class, so your styling belongs on a wrapper around it rather than on the element itself. Fighting a highlighter's markup with overrides is how a code block ends up with two backgrounds and inconsistent padding between languages.
Give the wrapper the scroll, not the page#
A wide code block or table inside a capped reading column will overflow. The wrapper needs overflow-x: auto; without it the *document* scrolls sideways on a phone, which is a much worse failure than a scrollable block.
What about images inside the body?#
Resolve the source in a defined order and render nothing when there is nothing.
- A CMS upload if there is one — it has dimensions and a CDN behind it.
- A file under `/public` if the path exists, checked at build time.
- Nothing at all otherwise. No placeholder, no broken image icon.
That last step is what lets a post reference artwork that has not been produced yet. The post is correct today and improves when the file lands, with no code change and no visible defect in between.
It is the same rule the cover images follow, and it generalises: where content references an asset that may not exist yet, absence should be invisible rather than a placeholder announcing its own incompleteness.
Check the file the right way#
The obvious build-time existence check resolves relative to the module, and components get bundled — so the path ends up relative to the build output rather than the project, and every lookup fails. It works in dev, where files are served from source, which is what makes it easy to miss. Resolve from the project root instead.
Captions belong in a figure#
The association is what assistive technology actually reads out; visual proximity on its own conveys nothing at all to it.
An image with a caption is a <figure> with a <figcaption>, not a paragraph underneath. It costs nothing and it is the difference between a caption that is associated with its image and one that merely sits below it.
How do you render tables from a flat format?#
As a custom block type, because Portable Text has no table concept and inventing one out of nested blocks is a trap.
{
"_type": "table",
"caption": "Two kinds of pagination",
"headerRow": true,
"rows": [
{ "cells": ["", "Offset", "Cursor"] },
{ "cells": ["Correct under inserts", "No", "Yes"] }
]
} A flat array of rows, each a flat array of strings. That is deliberately less capable than HTML tables — no colspan, no rich text inside cells — and the constraint is the point: an editor cannot build something the renderer cannot draw, and a comparison table almost never needs more.
Make the first cell of each row a header#
The left-hand column of a comparison table is labelling its row, so it is a <th scope="row"> rather than a <td>. Screen readers use that to announce which row a cell belongs to, and without it a table of numbers is read as a stream of numbers.
Wrap it, and let the wrapper scroll#
A four-column table will not fit a phone. The wrapper scrolls horizontally and the page does not — the same rule as code blocks, and the same failure if you skip it. The caption goes outside the scrolling region so it stays visible.
Shrink the type before you shrink the padding#
At narrow widths a table needs to give somewhere. Reducing font size a step and cell padding to the smallest token keeps four columns readable down to about 360px, which is usually enough to avoid a horizontal scroll at all — and a table you can read without scrolling is worth more than one at full size that you cannot.
What does the renderer teach you about the schema?#
That the two should be designed together, because every branch in the renderer is a decision the Studio should be enforcing.
If the renderer handles h2, h3, h4, bullets, numbers, blockquote, code and image — then the block schema should allow exactly those. An editor offered h6 will eventually use it, and a renderer that ignores it produces content that exists in the CMS and not on the site, which is confusing for everyone.
Every custom block needs a rendering plan first#
Shipping only the schema half of a feature creates content that exists nowhere the reader can ever actually see it.
It is tempting to add a block type in the Studio and render it later. In practice "later" means an editor creates content that vanishes. Add the schema type and the renderer branch in the same change.
It is the same discipline that keeps a design system from drifting: the thing that enforces a rule has to be the thing people actually interact with, not a document describing what they should do.
Keep the shared logic in one module#
This is the highest-leverage structural decision in the whole content layer.
Heading ids, the published test, related-post selection — anything both the renderer and the pages need should live in one file that both import. Two implementations of "which posts are published" is how a card links to a page that was never generated.
Portable Text is flat because flat data survives. The nesting your HTML needs is your renderer's job, and lists are where you find that out.
What does it cost?#
An afternoon to write, and very little after.
The eighty lines here handle paragraphs, three heading levels, both list types, blockquotes, code blocks, tables, images and four inline marks. It has needed changing twice since it was written, both times to add a block type, and each was a small branch beside the others.
The counterweight worth naming: this only stays cheap because the schema is restricted. A renderer written defensively against an open-ended content model grows quickly, and at that point the library exists for a reason. Own the renderer while it fits on a screen, and reach for a package when it does not.
Conclusion#
Coalesce list items before rendering — Portable Text stores each bullet as a sibling block, so a naive pass produces one list per item. Match on the listItem value rather than its presence, or an adjacent bulleted and numbered list merge into one and lose their numbering.
Resolve marks against markDefs and treat anything unresolved as a decorator. More broadly, make every unexpected input a no-op: an unknown block type, an empty block, a missing image, an unrecognised code language. A renderer that throws turns a content edit into a failed build.
Put heading ids in one function used by both the renderer and the contents list. Two implementations agree on most headings and disagree on the ones with punctuation, which produces a contents list that mostly works — the hardest kind of bug to notice.
Highlight code at build time so nothing ships to the client, give wide blocks their own horizontal scroll so the document never scrolls sideways, and resolve body images upload-then-file-then-nothing so unmade artwork is invisible rather than broken.
Then design the schema and the renderer together. Every branch in the renderer is a rule the Studio should enforce, and an editor offered a heading level nobody renders will eventually use it. Write your own while it fits on one screen — past that, the library's conventions start earning their keep. If you are modelling content and want the schema and the rendering thought about at the same time, that is worth an hour.