Rendering Portable Text in Next.js 16
Portable Text is just JSON until you render it. How I mapped Sanity's block format to React components — headings, links, and real code blocks — in the App Router.

Portable Text is just JSON until you render it. Sanity stores the body of every post as an array of typed blocks — paragraphs, headings, quotes, code — and hands you nothing about how they should look. That's a feature: the presentation lives in my React components, not in the CMS. Here's how the App Router turns that JSON into a real article. Part of the Building with AI series.
The body is structured, not HTML
A block of body isn't a string of markup — it's data. A paragraph is a block with children spans; a code snippet is a code block with a language and optional filename. Because it's data, I control every pixel of the output and can't inherit a security hole from pasted HTML.
1{
2 "_type": "block",
3 "style": "normal",
4 "children": [
5 { "_type": "span", "text": "Types are generated, ", "marks": [] },
6 { "_type": "span", "text": "never", "marks": ["strong"] },
7 { "_type": "span", "text": " hand-written.", "marks": [] }
8 ]
9}Map types to components
@portabletext/react walks that array and, for each node, calls the component I registered for its type. The whole renderer is a lookup table: block styles, marks, and custom types like code each get a React component. Nothing renders that I didn't opt into.
1import { PortableText, type PortableTextComponents } from "@portabletext/react";
2import { CodeBlock } from "./CodeBlock";
3
4const components: PortableTextComponents = {
5 block: {
6 h2: ({ children }) => <h2 className="mt-12 text-2xl font-semibold">{children}</h2>,
7 blockquote: ({ children }) => (
8 <blockquote className="border-l-2 pl-4 italic">{children}</blockquote>
9 ),
10 },
11 marks: {
12 link: ({ value, children }) => <a href={value?.href}>{children}</a>,
13 },
14 types: {
15 code: ({ value }) => <CodeBlock {...value} />,
16 },
17};
18
19export function PortableBody({ value }: { value: unknown }) {
20 return <PortableText value={value} components={components} />;
21}The highlighted line is where the blog's personality lives: the code type isn't a browser default, it's my own CodeBlock, so syntax highlighting, the filename caption, and highlighted lines all render exactly the way the schema promised in Designing the Content Model by Conversation.
Code blocks are first-class
Because a code block is structured data, the renderer gets code, language, filename, and highlightedLines as real fields — no fragile parsing of fenced markdown. The component highlights on the server so no client JS ships just to colour a snippet:
1export function CodeBlock({ code, language, filename }: {
2 code: string; language?: string; filename?: string;
3}) {
4 return (
5 <figure>
6 {filename && <figcaption>{filename}</figcaption>}
7 <pre data-lang={language}><code>{code}</code></pre>
8 </figure>
9 );
10}It renders once, at build time
The page is a Server Component, so the body is fetched through the typed service layer and rendered to HTML during the static build. The reader downloads finished markup, not a JSON payload plus a client renderer.
Structured input, opt-in output: nothing reaches the page that I didn't map on purpose.
That opt-in model is also a safety property. There's no dangerouslySetInnerHTML anywhere near the body — an unknown block type simply doesn't render, rather than injecting whatever a paste happened to contain.
Own the renderer and the body stops being markup you trust and becomes data you shape. Portable Text gives you the structure; the components give it a voice. With the body rendered, the next question is how it all looks — Styling at Speed: Tailwind v4 With an Agent.
