Dynamic Zones are a flexible content modeling feature in Strapi. They let editors compose pages from a library of reusable blocks without developer intervention for every layout change. But on the frontend, that flexibility has a cost.
A content editor adds a new block type on Tuesday, the React app crashes on Wednesday, and nobody notices until a customer reports a blank page. Or, more insidiously, TypeScript doesn't catch any of it because the entire block array was typed as any[] from day one.
If you've worked through content modeling in the Admin Panel, you know how to define Dynamic Zones. Rendering them reliably is where most teams trip. In a headless CMS setup, this is exactly where schema flexibility meets frontend responsibility.
Strapi 5 introduced two changes that materially affect this workflow: the response format and the population strategy for Dynamic Zones and components. If you're copying populate queries from a Strapi v4 tutorial, they will break.
By the end of this guide, you'll have a pattern that makes adding a new block type a two-file change with compile-time safety, eliminating runtime surprises.
In brief:
- Strapi returns a
__componentdiscriminator on every Dynamic Zone block, which serves as the anchor for type-safe rendering in TypeScript. - Discriminated unions turn "any block, anywhere" into a compile-time-checked switch across your entire block library.
- A component registry scales better than nested
switchorif/elsechains once you pass a handful of block types. - Strapi 5's flattened response format changes how you fetch and type this data, and Dynamic Zones require explicit
onfragments in the REST API. Most existing tutorials are outdated here.
How Strapi 5 Returns Dynamic Zone Data
Before diving into TypeScript patterns and React components, it helps to ground everything in the actual JSON shape Strapi 5 sends back. Most of the rendering code flows directly from this structure.
The Flattened v5 Response Format
The biggest structural change in Strapi 5's REST API is the removal of the data.attributes wrapper. In v4, accessing a page title meant reaching into data.attributes.title. In v5, it's just data.title. The same flattening applies to relations and media fields, with no more nested data.attributes chains to traverse.
This matters for Dynamic Zones because block fields are also flattened. A v4 block might have looked like { "__component": "blocks.hero", "id": 1, "attributes": { "heading": "Welcome" } }. In v5, it's simply { "__component": "blocks.hero", "id": 1, "heading": "Welcome" }.
Strapi 5 does support a Strapi-Response-Format: v4 header as a migration escape hatch that restores the old wrapping. All code in this article assumes the v5 default format. If you're incrementally migrating, that header buys you time, but plan to move off it.
The __component Discriminator
Every block in a Dynamic Zone includes a __component field formatted as category.name, for example blocks.hero, blocks.rich-text, or blocks.feature-grid. This field is the key to everything that follows. It's present on every block, it's always a string literal identifying the component type, and it's the anchor for TypeScript type narrowing.
The __component field existed in v4 too. What changed is the structure around it: the flattening described above. The discriminator itself works the same way.
documentId Is the New Primary Identifier
Strapi 5 introduces document ID as the primary identifier through documentId: a string that stays stable across locales and draft or published versions. However, component instances inside a Dynamic Zone still carry a numeric id for the specific record. This distinction matters for React keys: use the block's numeric id or a composite key like ${block.__component}-${index} when mapping over blocks, not the parent document's documentId.
Here's a complete v5 response for a page with a Dynamic Zone called blocks:
{
"data": {
"documentId": "abc123def456ghi789jkl012",
"title": "Home Page",
"slug": "home",
"locale": "en",
"createdAt": "2024-09-01T10:00:00.000Z",
"updatedAt": "2024-09-15T12:00:00.000Z",
"publishedAt": "2024-09-15T12:00:00.000Z",
"blocks": [
{
"__component": "blocks.hero",
"id": 1,
"heading": "Welcome to Our Site",
"subheading": "The best place on the web",
"ctaLabel": "Get Started",
"ctaUrl": "/start"
},
{
"__component": "blocks.rich-text",
"id": 2,
"content": "<p>Some rich text content here...</p>"
},
{
"__component": "blocks.feature-grid",
"id": 3,
"title": "Our Features",
"features": [
{ "id": 1, "title": "Fast", "description": "Lightning quick performance" },
{ "id": 2, "title": "Flexible", "description": "Adapts to your needs" }
]
}
]
},
"meta": {}
}Notice: blocks sits directly on data, not data.attributes, each block's fields are flat, with no attributes wrapper, and every block carries both __component and a numeric id.
The on Fragment Population Strategy
This is where teams migrating from v4 hit a wall. Strapi 5's shared population strategy is gone for components and Dynamic Zones. The on fragment is the documented way to explicitly populate Dynamic Zone content beyond one level deep.
Why populate=* Falls Short for Dynamic Zones
Dynamic Zones are polymorphic: each entry can be a different component type with a different set of fields, relations, and media. A wildcard like populate=* works one level deep. You get each block populated one level deep, but nested relations inside individual blocks won't be populated. Strapi's own populate guide explains that you need to explicitly define what to populate for Dynamic Zones.
The failure modes are specific and worth knowing:
| Syntax | Behavior in v5 |
|---|---|
populate: { blocks: true } | Scalar fields only; nested relations missing |
populate: { blocks: { populate: '*' } } | Populates relations, dynamic zones, and components inside blocks to a depth of one level; works for simple cases |
populate: { blocks: { populate: true } } | 500 error: Strapi can't resolve schemas generically |
For a real page builder with blocks that contain images, author relations, or nested repeatable components, populate=* won't cut it. Fine for prototyping, but broken for production.
The on Fragment Syntax
The on keyword lets you specify population rules per component type inside a Dynamic Zone. The populate-select reference documents the exact syntax.
Raw URL form:
GET /api/pages?populate[blocks][on][blocks.hero][populate]=*\
&populate[blocks][on][blocks.rich-text]=true\
&populate[blocks][on][blocks.feature-grid][populate]=*The equivalent qs.stringify object, recommended for anything beyond trivial queries since Strapi's query parser uses qs syntax:
const qs = require('qs');
const query = qs.stringify(
{
populate: {
blocks: {
on: {
'blocks.hero': {
populate: '*'
},
'blocks.rich-text': true,
'blocks.feature-grid': {
populate: '*'
}
}
}
}
},
{ encodeValuesOnly: true }
);
const res = await fetch(`/api/pages?${query}`);Always pass encodeValuesOnly: true to qs.stringify when building Strapi REST API queries to keep keys (including bracket characters) unencoded and the URLs more human-readable. Strapi can parse both encoded and unencoded brackets, so this is not strictly required for correct parsing.
Per-Component Field Selection
The on syntax also supports restricting which fields are returned per block type. This matters because a 15-block-type page where every block returns every field is how you accidentally ship a massive JSON payload:
const query = qs.stringify(
{
populate: {
blocks: {
on: {
'sections.hero': {
fields: ['title'],
populate: {
backgroundImage: {
fields: ['url', 'width', 'height']
}
}
},
'sections.faq': {
fields: ['question', 'answer']
}
}
}
}
},
{ encodeValuesOnly: true }
);Each component type gets its own field selection and nested population rules, independently of the others. Populate only what the block's React component actually renders.
Using @strapi/client vs. Raw fetch
Strapi's official client (@strapi/client) accepts the same JavaScript population object and handles URL serialization internally:
npm install @strapi/clientimport { strapi } from '@strapi/client';
const sdk = strapi({ baseURL: 'http://localhost:1337/api' });
const result = await sdk.collection('pages').find({
populate: {
blocks: {
on: {
'blocks.hero': { populate: '*' },
'blocks.rich-text': true,
'blocks.feature-grid': { populate: '*' }
}
}
}
});The SDK simplifies authentication, configured once at initialization, and CRUD operations. The tradeoff is that raw fetch gives you direct control over framework-specific options like Next.js cache and revalidate directives. All examples in this article work with both approaches since the on object structure is identical.
Support for autogenerated TypeScript types in @strapi/client is on the roadmap, but has not shipped yet.
Generating Types from Your Strapi Schema
Type safety for Dynamic Zones starts with having accurate TypeScript types for each block. In a headless CMS frontend, this is the line between a renderer that degrades gracefully and one that fails at runtime. There are three approaches, each with different tradeoffs.
Using ts:generate-types
Strapi's CLI command generates TypeScript definitions from your schema:
npm run strapi ts:generate-typesThis emits files to types/generated/:
types/
└── generated/
├── components.d.ts
└── contentTypes.d.tsThe --debug flag prints a detailed table of generated schemas, useful for verifying that your Dynamic Zone components were picked up.
There's a catch: these types are backend types. The generated files contain a declare module '@strapi/types' declaration that needs to be removed before using the types outside the Strapi project. They can also cause build issues, which you can address by excluding types/generated/** from your tsconfig.json so the Entity Service falls back to looser types.
One more thing to flag: the strapi openapi generate command currently emits {} for Dynamic Zone fields. This is a known limitation in Strapi's OpenAPI schema generation, so don't chase that path right now.
Writing Discriminated Unions by Hand
For frontends that live in a separate repository from the Strapi backend, hand-maintained types are often the most pragmatic choice. The __component field maps directly to TypeScript's discriminated union pattern:
// types/blocks.ts
export interface HeroBlock {
__component: 'blocks.hero';
id: number;
heading: string;
subheading: string;
ctaLabel: string;
ctaUrl: string;
}
export interface RichTextBlock {
__component: 'blocks.rich-text';
id: number;
content: string;
}
export interface FeatureGridBlock {
__component: 'blocks.feature-grid';
id: number;
title: string;
features: Array<{ icon: string; title: string; description: string }>;
}
// The discriminated union
export type DynamicZoneBlock =
| HeroBlock
| RichTextBlock
| FeatureGridBlock;Each variant has __component typed as a string literal, not just string. TypeScript uses this to narrow the type automatically when you branch on the field, with no manual casting inside the branch.
Keeping Types in Sync
The gap between your Strapi schema and your frontend types is where bugs hide. A few strategies:
- Monorepo with a shared types package: Export generated or hand-written types from a shared package that both your Strapi backend and React frontend depend on. This is the tightest coupling but also the lowest drift risk.
- Post-deploy copy script: A script that runs after schema changes, copying and transforming the generated types from the backend into the frontend's
src/typesdirectory, removing thedeclare modulewrapper along the way. - Manual sync with a checklist: For smaller teams, adding "update frontend types" to your content model change process works. It's low-tech, but it's honest about the current tooling gap.
The decision depends on your team size and repo structure. What matters is that the __component literal values in your TypeScript types match what Strapi actually returns.
Building a Component Registry
This is the core pattern. A component registry maps __component values to React components using a typed Record, with the discriminated union doing the heavy lifting for props.
The Registry Shape
The registry is a plain object where keys are __component strings and values are React components that accept the corresponding block type as props:
// components/registry.ts
import React from 'react';
import type {
DynamicZoneBlock,
HeroBlock,
RichTextBlock,
FeatureGridBlock,
} from '../types/blocks';
import HeroBlockComponent from './blocks/HeroBlock';
import RichTextBlockComponent from './blocks/RichTextBlock';
import FeatureGridBlockComponent from './blocks/FeatureGridBlock';
// Mapped type: ensures every union member has a registry entry
type BlockRegistry = {
[K in DynamicZoneBlock['__component']]: React.ComponentType<
Extract<DynamicZoneBlock, { __component: K }>
>;
};
export const blockRegistry: BlockRegistry = {
'blocks.hero': HeroBlockComponent,
'blocks.rich-text': RichTextBlockComponent,
'blocks.feature-grid': FeatureGridBlockComponent,
};The mapped type BlockRegistry is where the compile-time safety lives. DynamicZoneBlock['__component'] produces the union of all discriminant values, 'blocks.hero' | 'blocks.rich-text' | 'blocks.feature-grid'. Extract<DynamicZoneBlock, { __component: K }> resolves to the exact block type for each key. If you add a new variant to DynamicZoneBlock and forget to add it to the registry, TypeScript produces a compile error.
Why This Beats a Switch Statement
A switch on __component works, and actually provides better type narrowing inside each case, with no casts needed. But it doesn't scale the same way:
| Concern | Registry | Switch |
|---|---|---|
| Adding a new block type | One entry in the registry object | One case in every switch that handles blocks |
| Code organization | Registry defined once, imported anywhere | Switch must be co-located with rendering logic |
| Lazy loading | Swap direct imports for React.lazy or next/dynamic in the registry | Requires restructuring the switch |
| Test isolation | Registry entries are individually mockable | Requires testing the full function |
| Runtime extensibility | Entries can be added dynamically (plugin systems) | Cannot extend without modifying source |
The tradeoff: registry lookups lose the specific type at the call site due to a TypeScript limitation with index-signature lookups on discriminated unions, requiring an as any spread. The registry type guarantees correctness at assignment time, just not at the consumption site. This is a documented language constraint, not a pattern flaw.
For small block libraries (fewer than five types), a switch with a never exhaustiveness check is perfectly fine. For page builders that grow past that, the registry pays for itself.
The BlockRenderer Component
Here's the full renderer, roughly twenty lines of actual logic:
// components/BlockRenderer.tsx
import React from 'react';
import type { DynamicZoneBlock } from '../types/blocks';
import { blockRegistry } from './registry';
const UnknownBlockFallback: React.FC<{ block: { __component: string } }> = ({ block }) =>
process.env.NODE_ENV === 'development' ? (
<div style={{ border: '2px dashed red', padding: '1rem', margin: '1rem 0', fontFamily: 'monospace' }}>
<strong>Unknown block type:</strong> <code>{block.__component}</code>
</div>
) : null;
export const BlockRenderer: React.FC<{ block: DynamicZoneBlock }> = ({ block }) => {
const Component = blockRegistry[block.__component as keyof typeof blockRegistry];
if (!Component) {
return <UnknownBlockFallback block={block} />;
}
return <Component {...(block as any)} />;
};
export const DynamicZoneRenderer: React.FC<{ blocks: DynamicZoneBlock[] | null }> = ({ blocks }) => {
if (!blocks?.length) return null;
return (
<>
{blocks.map((block, i) => (
<BlockRenderer
key={`${block.__component}-${block.id ?? i}`}
block={block}
/>
))}
</>
);
};The key uses ${block.__component}-${block.id}, combining the component type with the numeric id Strapi returns for each block instance.
Handling Unknown Block Types Gracefully
When a content editor adds a new block type before the frontend is updated, the registry lookup returns undefined. The UnknownBlockFallback above handles this by rendering a visible warning in development and nothing in production.
For unexpected block types, log a warning or report the issue through your application's observability tooling:
if (!Component) {
if (process.env.NODE_ENV === 'development') {
console.warn(`[DynamicZone] Unknown block type: ${block.__component}`);
}
return null;
}For stricter compile-time guarantees at the API boundary, you can define a wider type for raw API responses and narrow it with a type guard:
const KNOWN_COMPONENTS = new Set<string>([
"blocks.hero",
"blocks.feature-grid",
"blocks.rich-text",
]);
type ApiBlock = DynamicZoneBlock | { __component: string; [key: string]: unknown };
function isKnownBlock(block: ApiBlock): block is DynamicZoneBlock {
return KNOWN_COMPONENTS.has(block.__component);
}This gives you a single point of update, the Set and the union type, when new blocks are added.
Populating Relations Inside Blocks
This is where most tutorials stop being useful. A blocks.hero with only scalar fields is easy. A sections.testimonial-grid that contains an array of testimonials, each with an author relation that has an avatar media field: that's where on fragments earn their keep.
Nested Population Inside on Fragments
The syntax for populating relations inside a specific block type nests populate objects within the on fragment. Here's a sections.testimonial-grid block that needs testimonial entries with their author and media populated:
import qs from 'qs';
const query = qs.stringify(
{
populate: {
sections: {
on: {
'sections.testimonial-grid': {
populate: {
testimonials: {
populate: {
author: {
populate: {
avatar: true,
},
},
media: {
populate: '*',
},
},
},
},
},
'sections.hero': {
populate: {
backgroundImage: true,
cta: true,
},
},
},
},
},
},
{ encodeValuesOnly: true }
);Each component type gets exactly the population depth it needs. The hero block populates its background image and CTA. The testimonial grid goes three levels deep. Neither pays for the other's complexity.
Avoiding the Populate-Everything Trap
A principled rule: populate only what the block's React component actually renders. Here's the contrast:
// ❌ Over-fetching: every field, every relation, every level
const bad = qs.stringify({
populate: {
sections: {
on: {
'sections.testimonial-grid': {
populate: {
testimonials: {
populate: '*',
},
},
},
},
},
},
});
// ✅ Selective: only the fields the component renders
const good = qs.stringify(
{
populate: {
sections: {
on: {
'sections.testimonial-grid': {
fields: ['title'],
populate: {
testimonials: {
fields: ['quote'],
populate: {
author: {
fields: ['name', 'title'],
populate: {
avatar: { fields: ['url', 'alternativeText'] },
},
},
},
},
},
},
},
},
},
},
{ encodeValuesOnly: true }
);One more thing to note: populate=deep plugins are explicitly not recommended for production by Strapi's support team, due to performance, stability, and scalability risks. Use explicit on fragments.
Type Narrowing When a Block Has Its Own Relations
Structure your types bottom-up: leaf types first, then compose them into block types. This keeps the discriminated union clean:
// types/media.ts
export interface StrapiMedia {
id: number;
documentId: string;
url: string;
alternativeText: string | null;
width: number;
height: number;
mime: string;
}// types/author.ts
import type { StrapiMedia } from './media';
export interface Author {
id: number;
documentId: string;
name: string;
title: string;
avatar: StrapiMedia | null;
}// types/testimonial.ts
import type { Author } from './author';
import type { StrapiMedia } from './media';
export interface Testimonial {
id: number;
quote: string;
author: Author | null;
media: StrapiMedia | null;
}// types/blocks.ts
import type { Testimonial } from './testimonial';
export interface TestimonialGridBlock {
__component: 'sections.testimonial-grid';
id: number;
title: string | null;
testimonials: Testimonial[];
}The Testimonial type lives in its own file. The TestimonialGridBlock references it. The discriminated union stays flat and readable, even as individual blocks gain complex nested structures.
Server-Side Rendering (SSR), Streaming, and Per-Block Rendering
If you're on Next.js App Router, the registry pattern is intended to accommodate both server and client components.
Server Components for Static Blocks, Client Components for Interactive Ones
The split is straightforward. Blocks that render static content (hero banners, rich text, stats sections) can be Server Components, with no 'use client' directive and zero client-side JavaScript. Blocks that need useState, event handlers, or browser APIs (accordions, tabs, carousels) become Client Components.
The registry doesn't care about the distinction. A Server Component and a Client Component are both valid React.ComponentType values. Import them normally. Next.js handles the boundary.
Suspense Boundaries Around Individual Blocks
A single <Suspense> boundary around the entire Dynamic Zone means the hero can't render until the slowest block (a map embed or a data visualization) finishes loading. Per-block Suspense boundaries create independent streaming chunks:
import { Suspense } from 'react';
import { BlockErrorBoundary } from './BlockErrorBoundary';
const SUSPENSE_FALLBACKS: Record<string, React.ReactNode> = {
'sections.map': <div className="h-96 w-full bg-gray-100 animate-pulse rounded-lg" />,
'sections.video': <div className="aspect-video w-full bg-gray-900 animate-pulse rounded-lg" />,
};
// Inside DynamicZoneRenderer
{blocks.map((block) => {
const Component = registry[block.__component];
if (!Component) return null;
const fallback = SUSPENSE_FALLBACKS[block.__component];
return (
<BlockErrorBoundary key={block.id} blockType={block.__component}>
{fallback ? (
<Suspense fallback={fallback}>
<Component {...block} />
</Suspense>
) : (
<Component {...block} />
)}
</BlockErrorBoundary>
);
})}Note the nesting order: error boundaries wrap Suspense. The other way around can't catch suspended render errors. In the Next.js App Router, error boundaries must be Client Components, and error.js files therefore need the 'use client' directive as part of Next.js's client-side error handling architecture.
Production Patterns
A few patterns that pay off once your page builder is past the prototype stage.
Lazy-loading heavy blocks. Maps, video players, and data-viz components don't belong in the initial bundle. Swap their registry entries for next/dynamic or React.lazy outside Next.js:
import dynamic from 'next/dynamic';
const MapBlock = dynamic(() => import('./blocks/MapBlock'), {
loading: () => <div className="h-96 bg-gray-100 animate-pulse" />,
ssr: false,
});The bundle for each heavy block is only fetched if that block type appears in the current page's Dynamic Zone data. The lazy() function requires a default export.
Error boundaries per block. Without them, one broken component blanks the entire page. A BlockErrorBoundary that catches render errors and reports them to your tracking service (such as Sentry) can keep the rest of the page functional:
componentDidCatch(error: Error, info: React.ErrorInfo) {
console.error(`Block render error [${this.props.blockType}]:`, error, info);
// reportToErrorTracking(error, { blockType: this.props.blockType });
}Per-block analytics without touching every component file. The DynamicZoneRenderer already iterates over blocks with their __component values. Adding viewport-tracking or render-timing instrumentation at the renderer level means individual block components stay clean.
Shipping Type-Safe Dynamic Zones
The pattern fits in one sentence: a TypeScript discriminated union keyed on __component, a component registry that maps those keys to React components, and on fragment population queries that fetch exactly what each block needs.
Adding a new block type becomes a two-file change. Define the type in your union, add the component to the registry, and TypeScript tells you at compile time if you missed either one.
If you haven't worked through Strapi's content modeling setup yet, that's the natural prequel to everything here. And if this feels like a lot to adopt at once, start with a single block type: define its interface, add it to a registry, wire up the on fragment. The pattern becomes useful fast.
Theodore is a Technical Writer and a full-stack software developer. He loves writing technical articles, building solutions, and sharing his expertise.