January 27, 2026 · 6 min read★ Featured
Dynamic rendering is the bridge between raw JSON and a rich, responsive UI. Next.js excels at this by providing a flexible framework to map data from the backend to React components in real time.
In previous posts, we explored content modeling patterns and how Wagtail serializes StreamField blocks into JSON for decoupled frontends. Now comes the next step: turning that structured data into interactive components that users can actually see and interact with.
Dynamic rendering is the bridge between raw JSON and a rich, responsive UI. Next.js excels at this by providing a flexible framework to map data from the backend to React components in real time. Instead of hardcoding every page layout, dynamic rendering lets you write one renderer that handles dozens of block types and adapts to any combination your editors throw at it.
Think of it this way: Wagtail provides the ingredients, Next.js is the kitchen, and dynamic rendering is the cooking process that transforms them into a meal your visitors can enjoy.
JSON alone doesn’t create an engaging experience, it’s just structured data. The frontend is responsible for turning that data into something humans can read, see, and interact with. For each block in your StreamField JSON, the frontend must decide:
Example JSON from Wagtail:
[
{ "type": "heading", "value": "Welcome to Our Site" },
{ "type": "paragraph", "value": "This content comes from Wagtail." }
]
The frontend interprets this JSON and turns it into rendered HTML elements with styling and spacing, ensuring that content is visually consistent and responsive.
By building a renderer, your pages can handle any mix of blocks while remaining predictable and maintainable, freeing editors to focus on content rather than layout.
Dynamic rendering is the process of mapping data to components at runtime. For StreamField JSON:
Example JSON with multiple types:
[
{ "type": "heading", "value": "Welcome to Our Site" },
{ "type": "paragraph", "value": "This content comes from Wagtail." },
{ "type": "image", "value": { "url": "/media/photo.jpg", "alt": "Photo" } }
]
Think of dynamic rendering like a vending machine:
This analogy highlights the predictability and simplicity that dynamic rendering brings: the frontend knows exactly how to deliver each piece of content, no surprises, no manual layout.
Typically, your page component handles layout—headers, footers, spacing, while a dedicated BlocksRenderer transforms the StreamField JSON into rendered content.
// app/blog/[slug]/page.js
import { BlocksRenderer } from "@/components/BlocksRenderer";
export default async function BlogPost({ params }) {
const response = await fetch(`https://api.example.com/pages/${params.slug}/`);
const data = await response.json();
return (
<article className="max-w-4xl mx-auto px-4 py-12">
<header className="mb-12">
<h1 className="text-4xl font-bold mb-4">{data.title}</h1>
<time className="text-gray-500">{data.publishedDate}</time>
</header>
<BlocksRenderer blocks={data.body} />
<footer className="mt-12 pt-8 border-t">
<p>Written by {data.author}</p>
</footer>
</article>
);
}
This separation keeps logic centralized:
Concept
The switch statement is the engine of dynamic rendering. It allows you to map each block type to a rendering function in one place, keeping logic organized and predictable.
Using a switch statement provides:
// components/BlocksRenderer.js
"use client";
export function BlocksRenderer({ blocks }) {
if (!Array.isArray(blocks)) return null;
return (
<>
{blocks.map((block, i) => {
if (!block?.value) return null;
const key = block.id || `${block.type}-${i}`;
switch (block.type) {
case "heading":
return <h2 key={key} className="text-3xl font-bold my-6">{block.value}</h2>;
case "paragraph":
return <p key={key} className="my-4 leading-relaxed">{block.value}</p>;
case "image":
return (
<figure key={key} className="my-8">
<img src={block.value.url} alt={block.value.alt} className="rounded-lg" />
</figure>
);
case "rich_text":
return <div key={key} dangerouslySetInnerHTML={{ __html: block.value }} />;
case "callout":
const styleClasses = {
info: 'bg-blue-50 border-blue-200',
tip: 'bg-green-50 border-green-200',
warning: 'bg-yellow-50 border-yellow-200',
};
return (
<div key={key} className={`border-l-4 p-4 my-6 ${styleClasses[block.value.style] || ''}`}>
{block.value.icon && <span className="text-2xl mr-2">{block.value.icon}</span>}
{block.value.title && <h3 className="font-bold mb-2">{block.value.title}</h3>}
<div dangerouslySetInnerHTML={{ __html: block.value.content }} />
</div>
);
default:
if (process.env.NODE_ENV === 'development') console.warn(`Unknown block type: ${block.type}`);
return null;
}
})}
</>
);
}
Not all blocks need their own component. The switch statement lets you choose between inline rendering for simple cases and extracted components for more complex ones.
Inline blocks are best when the rendering is simple, stateless, and easy to read directly in the switch statement.
Typical examples include headings, paragraphs, and images:
case "heading":
return (
<h2 key={key} className="text-3xl font-bold my-6">
{block.value}
</h2>
);
case "paragraph":
return (
<p key={key} className="my-4 leading-relaxed">
{block.value}
</p>
);
case "image":
return (
<figure key={key} className="my-8">
<img
src={block.value.url}
alt={block.value.alt}
className="rounded-lg"
/>
</figure>
);
Inline rendering keeps simple blocks close to the renderer logic and avoids unnecessary abstraction.
Extract blocks into dedicated components when behavior or structure becomes more complex, or when the same block is reused across multiple pages.
For example, a callout block can be extracted into its own component:
// components/CalloutBlock.js
export function CalloutBlock({ value }) {
const styleClasses = {
info: 'bg-blue-50 border-blue-200',
tip: 'bg-green-50 border-green-200',
warning: 'bg-yellow-50 border-yellow-200',
};
return (
<div className={`border-l-4 p-4 my-6 ${styleClasses[value.style] || ''}`}>
{value.icon && <span className="text-2xl mr-2">{value.icon}</span>}
{value.title && <h3 className="font-bold mb-2">{value.title}</h3>}
<div dangerouslySetInnerHTML={{ __html: value.content }} />
</div>
);
}
Then your renderer stays clean:
case "callout":
return <CalloutBlock key={key} value={block.value} />;
Rule of thumb: inline blocks are like ingredients you use immediately; extracted components are like pre-cooked dishes you assemble into the final page.
dangerouslySetInnerHTML safely for trusted HTMLExample safe rendering of HTML content:
<div dangerouslySetInnerHTML={{ __html: block.value }} />
Dynamic rendering turns JSON into interactive, flexible pages, bridging the gap between structured content and user experience.
dangerouslySetInnerHTMLReach out via the contact form or connect on Linkedin!