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:
__component discriminator on every Dynamic Zone block, which serves as the anchor for type-safe rendering in TypeScript. switch or if/else chains once you pass a handful of block types. on fragments in the REST API. Most existing tutorials are outdated here.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 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.
__component DiscriminatorEvery 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 IdentifierStrapi 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:
1{
2 "data": {
3 "documentId": "abc123def456ghi789jkl012",
4 "title": "Home Page",
5 "slug": "home",
6 "locale": "en",
7 "createdAt": "2024-09-01T10:00:00.000Z",
8 "updatedAt": "2024-09-15T12:00:00.000Z",
9 "publishedAt": "2024-09-15T12:00:00.000Z",
10 "blocks": [
11 {
12 "__component": "blocks.hero",
13 "id": 1,
14 "heading": "Welcome to Our Site",
15 "subheading": "The best place on the web",
16 "ctaLabel": "Get Started",
17 "ctaUrl": "/start"
18 },
19 {
20 "__component": "blocks.rich-text",
21 "id": 2,
22 "content": "<p>Some rich text content here...</p>"
23 },
24 {
25 "__component": "blocks.feature-grid",
26 "id": 3,
27 "title": "Our Features",
28 "features": [
29 { "id": 1, "title": "Fast", "description": "Lightning quick performance" },
30 { "id": 2, "title": "Flexible", "description": "Adapts to your needs" }
31 ]
32 }
33 ]
34 },
35 "meta": {}
36}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.
on Fragment Population StrategyThis 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.
populate=* Falls Short for Dynamic ZonesDynamic 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.
on Fragment SyntaxThe 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:
1GET /api/pages?populate[blocks][on][blocks.hero][populate]=*\
2 &populate[blocks][on][blocks.rich-text]=true\
3 &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:
1const qs = require('qs');
2
3const query = qs.stringify(
4 {
5 populate: {
6 blocks: {
7 on: {
8 'blocks.hero': {
9 populate: '*'
10 },
11 'blocks.rich-text': true,
12 'blocks.feature-grid': {
13 populate: '*'
14 }
15 }
16 }
17 }
18 },
19 { encodeValuesOnly: true }
20);
21
22const 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.
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:
1const query = qs.stringify(
2 {
3 populate: {
4 blocks: {
5 on: {
6 'sections.hero': {
7 fields: ['title'],
8 populate: {
9 backgroundImage: {
10 fields: ['url', 'width', 'height']
11 }
12 }
13 },
14 'sections.faq': {
15 fields: ['question', 'answer']
16 }
17 }
18 }
19 }
20 },
21 { encodeValuesOnly: true }
22);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.
@strapi/client vs. Raw fetchStrapi's official client (@strapi/client) accepts the same JavaScript population object and handles URL serialization internally:
1npm install @strapi/client1import { strapi } from '@strapi/client';
2
3const sdk = strapi({ baseURL: 'http://localhost:1337/api' });
4
5const result = await sdk.collection('pages').find({
6 populate: {
7 blocks: {
8 on: {
9 'blocks.hero': { populate: '*' },
10 'blocks.rich-text': true,
11 'blocks.feature-grid': { populate: '*' }
12 }
13 }
14 }
15});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.
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.
ts:generate-typesStrapi's CLI command generates TypeScript definitions from your schema:
1npm run strapi ts:generate-typesThis emits files to types/generated/:
1types/
2└── generated/
3 ├── components.d.ts
4 └── 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.
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:
1// types/blocks.ts
2
3export interface HeroBlock {
4 __component: 'blocks.hero';
5 id: number;
6 heading: string;
7 subheading: string;
8 ctaLabel: string;
9 ctaUrl: string;
10}
11
12export interface RichTextBlock {
13 __component: 'blocks.rich-text';
14 id: number;
15 content: string;
16}
17
18export interface FeatureGridBlock {
19 __component: 'blocks.feature-grid';
20 id: number;
21 title: string;
22 features: Array<{ icon: string; title: string; description: string }>;
23}
24
25// The discriminated union
26export type DynamicZoneBlock =
27 | HeroBlock
28 | RichTextBlock
29 | 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.
The gap between your Strapi schema and your frontend types is where bugs hide. A few strategies:
src/types directory, removing the declare module wrapper along the way. 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.
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 is a plain object where keys are __component strings and values are React components that accept the corresponding block type as props:
1// components/registry.ts
2
3import React from 'react';
4import type {
5 DynamicZoneBlock,
6 HeroBlock,
7 RichTextBlock,
8 FeatureGridBlock,
9} from '../types/blocks';
10
11import HeroBlockComponent from './blocks/HeroBlock';
12import RichTextBlockComponent from './blocks/RichTextBlock';
13import FeatureGridBlockComponent from './blocks/FeatureGridBlock';
14
15// Mapped type: ensures every union member has a registry entry
16type BlockRegistry = {
17 [K in DynamicZoneBlock['__component']]: React.ComponentType<
18 Extract<DynamicZoneBlock, { __component: K }>
19 >;
20};
21
22export const blockRegistry: BlockRegistry = {
23 'blocks.hero': HeroBlockComponent,
24 'blocks.rich-text': RichTextBlockComponent,
25 'blocks.feature-grid': FeatureGridBlockComponent,
26};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.
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.
BlockRenderer ComponentHere's the full renderer, roughly twenty lines of actual logic:
1// components/BlockRenderer.tsx
2
3import React from 'react';
4import type { DynamicZoneBlock } from '../types/blocks';
5import { blockRegistry } from './registry';
6
7const UnknownBlockFallback: React.FC<{ block: { __component: string } }> = ({ block }) =>
8 process.env.NODE_ENV === 'development' ? (
9 <div style={{ border: '2px dashed red', padding: '1rem', margin: '1rem 0', fontFamily: 'monospace' }}>
10 <strong>Unknown block type:</strong> <code>{block.__component}</code>
11 </div>
12 ) : null;
13
14export const BlockRenderer: React.FC<{ block: DynamicZoneBlock }> = ({ block }) => {
15 const Component = blockRegistry[block.__component as keyof typeof blockRegistry];
16
17 if (!Component) {
18 return <UnknownBlockFallback block={block} />;
19 }
20
21 return <Component {...(block as any)} />;
22};
23
24export const DynamicZoneRenderer: React.FC<{ blocks: DynamicZoneBlock[] | null }> = ({ blocks }) => {
25 if (!blocks?.length) return null;
26 return (
27 <>
28 {blocks.map((block, i) => (
29 <BlockRenderer
30 key={`${block.__component}-${block.id ?? i}`}
31 block={block}
32 />
33 ))}
34 </>
35 );
36};The key uses ${block.__component}-${block.id}, combining the component type with the numeric id Strapi returns for each block instance.
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:
1if (!Component) {
2 if (process.env.NODE_ENV === 'development') {
3 console.warn(`[DynamicZone] Unknown block type: ${block.__component}`);
4 }
5 return null;
6}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:
1const KNOWN_COMPONENTS = new Set<string>([
2 "blocks.hero",
3 "blocks.feature-grid",
4 "blocks.rich-text",
5]);
6
7type ApiBlock = DynamicZoneBlock | { __component: string; [key: string]: unknown };
8
9function isKnownBlock(block: ApiBlock): block is DynamicZoneBlock {
10 return KNOWN_COMPONENTS.has(block.__component);
11}This gives you a single point of update, the Set and the union type, when new blocks are added.
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.
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:
1import qs from 'qs';
2
3const query = qs.stringify(
4 {
5 populate: {
6 sections: {
7 on: {
8 'sections.testimonial-grid': {
9 populate: {
10 testimonials: {
11 populate: {
12 author: {
13 populate: {
14 avatar: true,
15 },
16 },
17 media: {
18 populate: '*',
19 },
20 },
21 },
22 },
23 },
24 'sections.hero': {
25 populate: {
26 backgroundImage: true,
27 cta: true,
28 },
29 },
30 },
31 },
32 },
33 },
34 { encodeValuesOnly: true }
35);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.
A principled rule: populate only what the block's React component actually renders. Here's the contrast:
1// ❌ Over-fetching: every field, every relation, every level
2const bad = qs.stringify({
3 populate: {
4 sections: {
5 on: {
6 'sections.testimonial-grid': {
7 populate: {
8 testimonials: {
9 populate: '*',
10 },
11 },
12 },
13 },
14 },
15 },
16});
17
18// ✅ Selective: only the fields the component renders
19const good = qs.stringify(
20 {
21 populate: {
22 sections: {
23 on: {
24 'sections.testimonial-grid': {
25 fields: ['title'],
26 populate: {
27 testimonials: {
28 fields: ['quote'],
29 populate: {
30 author: {
31 fields: ['name', 'title'],
32 populate: {
33 avatar: { fields: ['url', 'alternativeText'] },
34 },
35 },
36 },
37 },
38 },
39 },
40 },
41 },
42 },
43 },
44 { encodeValuesOnly: true }
45);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.
Structure your types bottom-up: leaf types first, then compose them into block types. This keeps the discriminated union clean:
1// types/media.ts
2export interface StrapiMedia {
3 id: number;
4 documentId: string;
5 url: string;
6 alternativeText: string | null;
7 width: number;
8 height: number;
9 mime: string;
10}1// types/author.ts
2import type { StrapiMedia } from './media';
3
4export interface Author {
5 id: number;
6 documentId: string;
7 name: string;
8 title: string;
9 avatar: StrapiMedia | null;
10}1// types/testimonial.ts
2import type { Author } from './author';
3import type { StrapiMedia } from './media';
4
5export interface Testimonial {
6 id: number;
7 quote: string;
8 author: Author | null;
9 media: StrapiMedia | null;
10}1// types/blocks.ts
2import type { Testimonial } from './testimonial';
3
4export interface TestimonialGridBlock {
5 __component: 'sections.testimonial-grid';
6 id: number;
7 title: string | null;
8 testimonials: Testimonial[];
9}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.
If you're on Next.js App Router, the registry pattern is intended to accommodate both server and client components.
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.
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:
1import { Suspense } from 'react';
2import { BlockErrorBoundary } from './BlockErrorBoundary';
3
4const SUSPENSE_FALLBACKS: Record<string, React.ReactNode> = {
5 'sections.map': <div className="h-96 w-full bg-gray-100 animate-pulse rounded-lg" />,
6 'sections.video': <div className="aspect-video w-full bg-gray-900 animate-pulse rounded-lg" />,
7};
8
9// Inside DynamicZoneRenderer
10{blocks.map((block) => {
11 const Component = registry[block.__component];
12 if (!Component) return null;
13
14 const fallback = SUSPENSE_FALLBACKS[block.__component];
15
16 return (
17 <BlockErrorBoundary key={block.id} blockType={block.__component}>
18 {fallback ? (
19 <Suspense fallback={fallback}>
20 <Component {...block} />
21 </Suspense>
22 ) : (
23 <Component {...block} />
24 )}
25 </BlockErrorBoundary>
26 );
27})}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.
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:
1import dynamic from 'next/dynamic';
2
3const MapBlock = dynamic(() => import('./blocks/MapBlock'), {
4 loading: () => <div className="h-96 bg-gray-100 animate-pulse" />,
5 ssr: false,
6});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:
1componentDidCatch(error: Error, info: React.ErrorInfo) {
2 console.error(`Block render error [${this.props.blockType}]:`, error, info);
3 // reportToErrorTracking(error, { blockType: this.props.blockType });
4}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.
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.