@portabletext/to-html

Renders Portable Text content directly into HTML strings using a fully overridable component system.

Library
npm
v6.0.0
66stars
MIT License

Repository Health

Pre-computed score based on development activity, maintenance, community, maturity, and trend momentum.How we score it →
58/100Fair
Development Activity64
Maintenance52
Community40
Maturity56
Momentum20

Technical Analysis

AI-assessed by reading the actual repository — architecture, code quality, innovation, and documentation.How we score it →
73/100Good
Architecture78
Code Quality82
Innovation55
Learning Curve75

@portabletext/to-html converts Portable Text — the block-based rich text format used by Sanity and other structured-content platforms — into raw HTML strings. It walks the block, mark, and list tree produced by a Portable Text value and invokes a matching component function for each node type, concatenating the returned fragments into the final markup, with no DOM or browser APIs involved.

Every part of the rendering pipeline is overridable: block styles, marks (both simple decorators and data-carrying annotations like links), lists, list items, hard breaks, and even the HTML-escaping function can be swapped out through the components option, which is deep-merged with the library’s sensible defaults via mergeComponents. Because it works entirely with strings and has no DOM dependency, it fits naturally into static site generators, email-rendering pipelines, and other server-side or non-React environments.

What You Get

  • A toHTML() function that converts a Portable Text value (single block or array) directly into an HTML string
  • A default component set covering every standard Portable Text node type: block styles, marks, lists, list items, and hard breaks
  • A mergeComponents deep-merge system that layers custom render functions over the library’s defaults without needing to redefine unrelated node types
  • Standalone escapeHTML and uriLooksSafe exports for safely escaping text/attributes and validating link URIs before rendering them
  • Configurable handling of unknown/unmapped node types via onMissingComponent — log a warning, run a custom callback, or silence it entirely

Common Use Cases

  • Rendering Sanity CMS rich text content to HTML for static site generators that don’t ship a React runtime
  • Generating HTML email bodies from Portable Text content stored in a CMS
  • Server-side rendering of CMS content inside non-React backends, Node scripts, or serverless functions
  • Producing sanitized HTML previews or exports of structured content for search indexing or RSS/Atom feeds

Under The Hood

Architecture Rendering starts in src/to-html.ts, where toHTML() normalizes the input into an array, runs it through @portabletext/toolkit’s nestLists to group flat list-item blocks into a virtual list tree, merges any user-supplied components onto defaultComponents, and then dispatches each top-level node into a closure-scoped getNodeRenderer. That factory returns a single recursive renderNode function that type-switches on toolkit predicates (isPortableTextToolkitList, isPortableTextListItemBlock, isPortableTextToolkitSpan, isPortableTextBlock, isPortableTextToolkitTextNode) to route each node to a dedicated render handler, each of which looks up the matching component function — falling back to an unknown* component plus an optional warning callback — and returns a plain HTML string that its caller concatenates. There is no retained virtual DOM or intermediate tree after rendering; each node is serialized once and discarded.

Tech Stack The package is pure TypeScript with exactly two runtime dependencies, @portabletext/toolkit (shared block/list/mark traversal helpers used across the @portabletext family) and @portabletext/types (shared type definitions). It builds with tsdown into ESM-only output, is linted with oxlint in type-aware, deny-warnings mode, formatted with oxfmt, documented via typedoc, and tested with vitest. Releases are automated through Changesets with a dedicated GitHub Actions release workflow, and the repo lives in a pnpm workspace alongside sibling @portabletext packages.

Code Quality The test suite (test/portable-text.test.ts, test/mutations.test.ts, test/serializers.test.ts, test/escaping.test.ts) runs against 40 fixture files covering edge cases like nested lists, custom block types, injection attempts in link hrefs, and non-mutation of input data, giving broad behavioral coverage of the renderer. tsconfig.json extends @sanity/tsconfig/strictest, and oxlint runs with --type-aware --type-check --deny-warnings, so type errors and lint violations both fail CI rather than just warning. Naming is consistent and small-surface (renderNode, renderBlock, renderList, etc.), and the escapeHTML/uriLooksSafe helpers show deliberate attention to XSS-adjacent edge cases (protocol allowlisting, #//-prefixed URIs, query/hash-aware colon handling).

API Design The public surface is minimal and low-boilerplate: a single toHTML(value, options) call is enough to get working output with zero configuration, since every component has a sensible default. Customization follows one consistent shape — a components object with types/marks/block/list/listItem keys, each either a single function or a per-key-name map — so learning the override pattern for one node type immediately transfers to the others. The onMissingComponent escape hatch (callback, false, or default warning) avoids forcing consumers to enumerate every possible content type up front, and defaultComponents is exported directly so overrides can delegate back to the built-in renderer instead of reimplementing it.

Join founders buildingwith open source

Opinionated takes, migration guides, cost-saving tips, and insights from the open source ecosystem.

Subscribe on Substack
Join 750+ subscribers

Search