February 3, 2026 · 5 min read★ Featured
Learn how to handle nested and conditional StreamField blocks in Next.js using a single recursive renderer, keeping content flexible and layouts composable.
In the previous post, we focused on behavior: how to add interactivity like copy buttons and shared lightboxes directly inside a dynamic StreamField renderer. Centralizing interaction logic gave us clarity and control without sacrificing flexibility.
This article moves the conversation forward by focusing on structure.
Real-world content is rarely flat. Editors want columns, grouped sections, conditional elements, and reusable layouts. In Wagtail, this naturally leads to nested StreamField blocks and structural blocks that contain other blocks.
The challenge on the frontend is rendering this complexity without exploding the number of components or hardcoding layout assumptions. The solution is surprisingly consistent with what we already built: recursive rendering.
Structural blocks don’t represent content on their own. Instead, they describe how other blocks are arranged.
Common examples include:
In StreamField JSON, these blocks usually contain a list of child blocks rather than a simple value.
{
"type": "columns",
"value": [
{
"type": "column",
"value": [
{ "type": "heading", "value": "Left column" },
{ "type": "paragraph", "value": "Some text" }
]
},
{
"type": "column",
"value": [
{ "type": "image", "value": { "url": "/img.jpg" } }
]
}
]
}
At first glance, this can look intimidating. In practice, it simply means that the renderer must be able to render blocks inside other blocks.
The key insight is that the renderer does not need special knowledge of depth or hierarchy. It only needs to know how to render a list of blocks.
If a block contains other blocks, the renderer can call itself.
export function BlocksRenderer({ blocks }) {
if (!Array.isArray(blocks)) return null;
return (
<>
{blocks.map((block, i) => {
switch (block.type) {
case "columns":
return <ColumnsBlock key={i} value={block.value} />;
case "heading":
return <h2 key={i}>{block.value}</h2>;
case "paragraph":
return <p key={i}>{block.value}</p>;
default:
return null;
}
})}
</>
);
}
The renderer stays simple. Structural complexity is expressed through composition, not conditionals.
Imagine a cooking blog where each recipe is made of nested sections:
A BlocksRenderer handles this by recursively rendering blocks inside blocks. Each structural block defines layout, while child content remains flexible and isolated, keeping the renderer simple and composable.
Example
Columns are a canonical example of a nested block. Each column is a container for more blocks, not a final piece of content.
function ColumnsBlock({ value }) {
return (
<div className="grid md:grid-cols-2 gap-8 my-12">
{value.map((column, i) => (
<div key={i}>
<BlocksRenderer blocks={column.value} />
</div>
))}
</div>
);
}
Several important things are happening here:
This pattern scales naturally to three columns, nested sections, or more complex layouts.
This is a callout on Column 1!
This is a callout on Column 2!
Concept
Another common requirement is conditional rendering: showing one group of blocks based on editor choice or data state.
Instead of branching logic throughout the page, conditionals can be expressed as structural blocks.
{
"type": "conditional",
"value": {
"condition": "has_image",
"blocks": [
{ "type": "image", "value": { "url": "/img.jpg" } }
]
}
}
On the frontend, the renderer evaluates the condition and renders the nested blocks if it passes.
case "conditional":
if (!evaluateCondition(block.value.condition)) return null;
return (
<BlocksRenderer
key={key}
blocks={block.value.blocks}
/>
);
The important detail is that conditions wrap content, rather than fragmenting rendering logic across the codebase.
Recursive rendering works because it mirrors the mental model of StreamField itself:
This keeps complexity linear even as layouts become more expressive. Adding a new structural block doesn’t require changing existing ones; it only requires defining how that block arranges its children.
Use recursive rendering inside a single BlocksRenderer to support nested and conditional StreamField blocks.
Flattening content or resolving structure server-side was considered, but rejected because it:
Reach out via the contact form or connect on Linkedin!