How to Render Portable Text in React, Vue, and Astro
You modeled your content the right way. Rich text lives as structured data, not as a glob of HTML in a database column.
You modeled your content the right way. Rich text lives as structured data, not as a glob of HTML in a database column. Then you hit the frontend, call `dangerouslySetInnerHTML` on a serialized string, and watch your careful schema collapse into markup you can't style, can't query, and can't reuse on the next channel. Worse, every embedded image, callout, or product reference becomes a special case nobody documented.
Sanity's content model keeps rich text as typed blocks rather than serialized HTML, which is why Portable Text exists as a first-class format rather than an afterthought.
That failure mode is what Portable Text was built to prevent. It represents rich text as an array of typed blocks and inline spans, so a heading is a heading and a custom `productRef` is a real object with real fields, not a fragile HTML tag you have to parse back out. The cost of getting this wrong is paid every time you add a channel, redesign a component, or hand the content to an AI agent that needs to read it.
This guide is the practical version: how to render the same Portable Text payload in React, Vue, and Astro without rewriting your content model three times. We cover the serializer mental model, custom block and mark components, embedded objects, and the operational concerns (sanitization, performance, previews) that separate a demo from a production frontend.
Why Portable Text instead of an HTML string
The temptation when you ship a CMS frontend is to store rich text as HTML and pour it into the DOM. It works in the demo and rots in production. HTML in a column couples your content to one presentation, one set of class names, and one runtime. The day marketing wants the same article on a native app, in an email, or summarized by an agent, you are parsing tag soup to recover meaning you already had and threw away.
Portable Text takes the opposite stance. Rich text is an array of blocks. A normal paragraph is a block of type `block` with a `style` (like `normal` or `h2`) and a `children` array of spans, each carrying text and a list of `marks`. Marks split into two kinds: simple decorators like `strong` and `em`, and annotations, which are full objects with their own fields, the classic example being a link with an `href` plus, say, a `rel` and an internal reference. Anything that is not text at all, an image, a callout, an embedded product, lives as its own object in the same top-level array.
Because every node is typed, rendering becomes a mapping problem rather than a parsing problem. You declare which component handles a `block` of style `h2`, which handles the `link` annotation, and which handles your custom `callout` type. Nothing is implicit. The structure is also portable across channels and legible to machines, which is exactly why the format carries the same shape whether a React component, a Vue template, or an AI summarization step consumes it. You model the meaning once, then decide separately how each surface presents it.
The serializer mental model that ports across frameworks
Every Portable Text renderer, regardless of framework, is the same idea wearing different syntax: a dictionary of serializers keyed by node kind. You are answering four questions. How do I render each block style? Each list and list item? Each mark, both decorators and annotations? And each custom object type that is not text? Get that mental model straight once and the React, Vue, and Astro implementations stop feeling like three different problems.
In React, `@portabletext/react` exposes a `PortableText` component that accepts a `components` prop. That object has keys for `block`, `marks`, `types`, `list`, and `listItem`, and each value is a component that receives the node and its children. In Vue, `@portabletext/vue` mirrors the same shape through a `components` prop or plugin registration, rendering with Vue's own component system. Astro can do either: drop the React or Vue renderer into an island, or use a framework-agnostic toolkit like `astro-portabletext` that maps node types to `.astro` components and ships zero client JavaScript when the content is static.
The payoff of the shared mental model is real reuse. The schema that defines a `callout` object in Sanity Studio is one `defineType` declaration. The contract it produces, an object with a `tone` field and a Portable Text body, is identical no matter which frontend reads it. You write three thin presentation layers, not three content models. When you change the schema, TypeGen regenerates the TypeScript types from your `defineType` definitions, so every serializer in every framework gets a compile error pointing at exactly the field that moved, instead of a silent runtime gap.
Rendering custom blocks, marks, and annotations
The interesting work is never the plain paragraph. It is the heading that needs an anchor, the link annotation that might point at an internal document, and the inline footnote your editors insist on. This is where storing HTML would have already failed you and where typed nodes pay off.
Start with block styles. A serializer for `block` inspects `style` and returns the right element: an `h2` with a slugified `id`, a `blockquote`, or a paragraph wrapped in your design-system `Text` component. Because the style is a known string from your schema, not a guessed tag, you can map it to whatever component your system uses without regex. Lists work the same way: the renderer groups consecutive list items for you, so you supply a `list` serializer for the `ul` or `ol` shell and a `listItem` serializer for each entry.
Marks are two layers. Decorators like `strong` and `em` are trivial wrappers. Annotations are where Portable Text earns its keep, because an annotation is a real object. A `link` annotation can carry both an external `href` and a reference to another document; your serializer reads the resolved fields and renders an internal route or an external anchor with the right `rel`. The same pattern handles a `productRef` mark that turns a span of text into a live, queryable link to a product in Content Lake. Custom non-text objects go in the `types` map: a `callout` object renders your alert component, an `image` object renders a responsive picture with the asset's metadata. None of this requires a separate add-on. The serializer map is the whole extension mechanism, and it is identical in spirit across React, Vue, and Astro.
Embedded objects: images, code, and references
A real article body is not just styled text. It is a hero image with a caption, a fenced code block with a language, an embedded video, and the occasional inline reference to another entry. In Portable Text every one of these is a first-class object sitting in the same block array, which is precisely what makes them renderable without hacks.
Take an image. In the content it is an object of type `image` with an asset reference and optional fields like `alt`, `caption`, and a hotspot for art-directed cropping. Your `types.image` serializer reads those fields and emits a responsive element, often using the image URL builder to request exactly the dimensions and format the layout needs. The alt text is structured data, not an afterthought buried in a tag attribute, so accessibility and SEO checks can validate it before publish. A code block is the same story: an object with `code` and `language` fields that your serializer hands to a syntax highlighter.
References are where the structured model genuinely outclasses HTML. An embedded `reference` is a pointer, not a copy. With GROQ you resolve it at query time using the dereference operator, `... "author": author->{name, slug}`, so the rendered component receives the linked document's current fields in the same single round trip that fetched the article. No second request, no stale denormalized copy. When the referenced document changes, the next query reflects it. That projection-in-one-query behavior is the half of rendering people forget: the cleanest serializer in the world still needs the embedded references resolved, and GROQ shapes that payload for you before it ever reaches the component.
Sanitization, performance, and previews in production
Two things separate a tutorial renderer from one you can ship. The first is trust. Because Portable Text is structured rather than raw HTML, you are not running `dangerouslySetInnerHTML` over editor output, which closes off the most common stored-XSS path by default. You still validate annotation values, an external `href` should be checked against an allowlist of schemes so an editor cannot smuggle a `javascript:` link, but you are validating typed fields, not sanitizing an opaque string. The structure is the security boundary.
The second is performance and previews. On a static Astro page, rendering Portable Text to `.astro` components ships no client runtime for the body, so a long article stays fast and the serializer cost is paid at build time. When you do need interactivity, you hydrate only the islands that need it. For editorial workflows, the harder problem is letting authors see changes before they publish without abandoning the headless contract. This is where the Presentation Tool and Visual Editing matter: editors get clickable, live preview of the real frontend, with Content Source Maps tracing each rendered string back to the field that produced it, while the Live Content API streams updates so a draft edit shows up without a manual refresh. Content Releases let a batch of changes go live together on a schedule instead of trickling out one save at a time. The point is that none of this requires you to compromise the rendering layer. The same serializer map drives production, preview, and scheduled release; you are not maintaining a parallel preview renderer, which is the trap teams fall into when their rich text is HTML.
Where Sanity fits: one model, many renderers
Step back and the pattern is the larger argument for treating content as a system rather than a publishing endpoint. Sanity is the Content Operating System for the AI era: you model rich text once as Portable Text, store it in Content Lake, query it with GROQ, and render it on as many surfaces as the business needs from that single typed source. The schema is code you own in `sanity.config.ts`, so the `callout` or `productRef` object you invented is a real type, not a vendor's fixed field set.
That is the substantive difference from CMSes that stop at HTML output. A platform that hands you a serialized rich-text string has already decided your presentation for you, and every new channel becomes a parsing and migration project. Portable Text keeps the meaning intact all the way to the component boundary, where React, Vue, and Astro each apply their own thin serializer. The same array of typed blocks feeds a marketing site, a native app, an email build step, and an AI agent that needs to read structured content rather than scrape markup.
The operational glue reinforces it. TypeGen turns your schema into TypeScript, so a renamed field surfaces as a compile error in every serializer rather than a blank space in production. Sanity Studio, the customizable React editor you actually ship, lets you build the custom input that authors a `callout` so the data going in matches the component rendering it out. Visual Editing closes the loop for editors. You end up with one content model, several presentation layers, and no fragile HTML in the middle holding it all hostage.
Rendering structured rich text: Sanity vs other headless platforms
| Feature | Sanity | Contentful | Strapi | Storyblok |
|---|---|---|---|---|
| Rich-text format | Portable Text: a typed array of blocks, decorators, object annotations, and custom non-text objects, portable across channels and machine-readable. | Rich Text field stores a structured JSON document tree that you traverse with the @contentful/rich-text-react-renderer and equivalents. | Blocks field or a Markdown/HTML field depending on config; the rich-text blocks model is newer and less granular for custom inline objects. | Richtext field returns a JSON schema you render via @storyblok/richtext or the React/Vue resolvers. |
| Custom embedded objects in body | Any defineType object (callout, productRef, image with hotspot) lives inline and renders via the types serializer map. No fixed field set. | Embedded entries and assets supported via node types, resolved by reference; custom inline types map to your renderer's node handlers. | Dynamic zones and components can embed structured blocks, though deeply custom inline marks need more manual wiring. | Embeds blocks and components inside richtext; custom inline schemas are supported through the resolver but tied to Storyblok's block model. |
| Resolving references at query time | GROQ dereference (author->{name, slug}) returns linked document fields in the same single round trip that fetches the body. | GraphQL or REST with include depth; linked entries returned alongside, with link-resolution limits to manage depth. | REST/GraphQL with populate to expand relations; deep nesting requires explicit populate paths per level. | Resolve relations via resolve_relations params; depth and link resolution are configured per request. |
| Multi-framework renderers | Official @portabletext/react and @portabletext/vue, plus astro-portabletext, all driven by the same components serializer map. | Official rich-text renderers for React and others; the same node-handler pattern, scoped to the Contentful Rich Text tree. | Community and official renderers exist for the blocks field; coverage varies by framework and field type. | Official richtext resolvers for React and Vue; Astro typically consumes them via an island. |
| Schema-to-types for safe serializers | TypeGen generates TypeScript from defineType schemas, so a renamed field breaks the build at the serializer, not silently in production. | TypeScript types available via codegen tools against the GraphQL schema or content types. | Type generation available for the API; rich-text block typing is less precise for custom inline objects. | TypeScript types via the management API and community generators; richtext node typing is generic. |
| Editor-to-preview loop | Presentation Tool plus Visual Editing and Content Source Maps trace each rendered string back to its field; Live Content API streams draft updates. | Live Preview is available and maps to your frontend via their SDK; setup is a separate integration step. | Preview is configured per project, typically via a preview route and draft tokens. | Visual Editor with live preview is a core strength; mapping uses Storyblok's bridge in the editor. |