Skip to main content
Adrean Jan Quidor (Kofeejan)
All blogs

Rich Content Without a Page Builder

SanityPortable TextAstroContent ArchitectureTypeScript

Problem

About, blog, and project writing needs more than one long text field. Authors need headings, paragraphs, lists, links, images, videos, and code. A page builder can provide these tools, but it can also expose frontend layout choices as editorial data.

That coupling creates a long-term problem. Fields such as eyebrow text, column count, card style, and section layout describe a current design. Once stored in Content, they make editors responsible for component composition. A redesign must then preserve old layout data or migrate it.

The opposite extreme also fails. One giant editorial document can place About, every blog, and every project in one record. It gives Studio one entry point, but it removes independent drafts, publishing, queries, and document history.

I needed rich writing without turning Sanity into a visual page builder.

Decision

I chose a shared Rich Content Body based on Sanity Portable Text. Portable Text stores writing as an ordered array of typed blocks. About, blog, and project documents use the same body model, but each document remains independent.

The body is a Flowing Body. A Flowing Body gets its structure from block order and headings. It does not contain named frontend sections or layout fields. Editors control the writing sequence. The frontend controls how that sequence looks.

Structured metadata stays outside the body. A blog keeps its slug, excerpt, publish date, tags, and featured flag. A project keeps its cover image, slug, tags, live URL, repository URL, and external video URL. These fields support routing, cards, filtering, and other behavior that is not part of article flow.

Implementation

richContentBodySchema defines an ordered array with at least one item. It also requires meaningful text in at least one block. An image-only or video-only body does not pass Contract validation.

Text blocks use Portable Text metadata. Each block has _key, _type, style, children, and markDefs. Supported styles are normal text, h2, and h3. Lists use bullet or number with a positive level. Text spans keep mark references, so formatting and links survive Snapshot projection.

Link validation accepts safe relative links and absolute http, https, or mailto links. It rejects protocol-relative URLs, unsafe schemes, and control characters. The React renderer applies the same safety policy before it creates an anchor. External HTTP links open in a new tab with noopener noreferrer.

Rich images contain a Sanity asset reference, optional crop and hotspot data, optional alternative text, and source dimensions. Alternative text remains optional because the accepted editorial rule warns authors instead of blocking publication. During display preparation, optimizeRichContentBody() replaces the asset reference with a prepared image shape. It uses one centralized recipe with 640, 960, and 1280 pixel source widths and a maximum content width of 65ch.

This conversion keeps Sanity delivery knowledge away from the renderer. RichContentBody.tsx receives a DisplayRichContentImage and sends it to OptimizedImg. The component does not build Sanity URLs.

Video blocks support YouTube and Loom only. Contract validators accept known URL forms from those providers. The renderer converts valid YouTube links to youtube-nocookie.com embed URLs. It converts Loom share links to Loom embed URLs. An unsupported or malformed value produces no iframe.

Code blocks store required code and an optional language. The Contract transform trims the language and uses text when the field is missing or empty. Snapshot Query also normalizes older projected shapes to the same fallback. The renderer preserves the value in data-language, even though syntax highlighting is not part of the current component.

One React component owns all body rendering. RichContentBody maps each supported item to the portfolio design system. About, blog, and project views all use that component. A typography or safety change has one frontend owner.

The Snapshot Query keeps Portable Text structure intact. It projects rich images with asset metadata, rich videos with URLs, and code with its fallback language. Its cleanup logic removes unrelated Sanity metadata while preserving _key and _type values that Portable Text needs.

Existing fields moved into this body by meaning. A blog lede became the first body content, and old sections joined the ordered flow. Project industry, business outcome, and stack context also became body content. Cover images and behavioral URLs stayed structured.

Rejected Options

I rejected a page builder with frontend layout fields. Such a builder lets editorial records control presentation details that belong to components. It also makes a future redesign interpret historical layout choices.

I rejected one large editorial document. Studio can group independent documents under one editorial structure without merging their identity. Separate documents preserve private drafts, individual publishing, direct queries, and focused migrations.

I rejected a Markdown-only body. Markdown handles common text, but structured Sanity images need crop, hotspot, dimensions, and asset references. Typed blocks also validate provider-specific videos and code metadata at the Contract boundary.

I rejected uploaded video files and unrestricted providers. Video delivery adds storage, encoding, and player concerns. YouTube and Loom links cover the accepted scope with a small validation and rendering surface.

I rejected required alternative text at Contract validation. Some editorial images can be decorative, and the accepted Studio behavior warns instead of blocking. The renderer uses an empty string when alternative text is absent.

Accepted Tradeoffs

The block model supports a limited writing vocabulary. It has no table, footnote, callout, gallery, or arbitrary embed block. A new editorial need requires a Contract change, Snapshot Query support, Studio schema work, and renderer support.

Portable Text also brings structural metadata that plain Markdown does not need. Projection must preserve references between spans and mark definitions. Tests must cover malformed references and duplicate keys.

The frontend owns all presentation. Editors cannot select a two-column section or special card treatment from the body. That limit is intentional, but it means a truly distinct composition needs a frontend change.

Optional alternative text permits inaccessible authoring. Studio warnings and editorial review must catch that issue because Contract validation does not block it.

Lesson

Rich content does not require layout content. Ordered semantic blocks give authors useful control while keeping the design system in code. Structured metadata can remain beside the body when software needs it for routing, summaries, or external actions.

A shared body also reduces renderer drift. About, blog, and project writing follows one safety policy, one image path, and one set of supported blocks.

Next-Project Rule

For my next editorial system, I will start with semantic writing blocks and a short provider allowlist. I will keep layout fields out until an editorial need cannot fit ordered content. I will keep behavioral metadata outside the body. I will also require one validator and one renderer for every shared block type.