Martin Kulvedrøsten Myhre
12:11-1/23/2025
When we have built content-heavy websites with Next.js and a headless CMS (Sanity, in our case), one common challenge in all of the projects is rendering dynamic content blocks without bloating the website with JavaScript. Here's a scalable pattern for handling block-based content with proper typing and dynamic imports.
The solution consists of three main parts:
1. The Root Block Renderer
tsx
type BlocksProps = {
blocks: Block[];
textProps?: TextPassThroughProps;
isText?: boolean;
className?: string;
};
export default function BlockRenderer({
blocks,
textProps,
isText,
className,
}: BlocksProps) {
return (
<BlockContext
textProps={textProps}
isText={isText}
className={className}
>
<div className={clsx('content-wrapper', className)}>
{blocks.map((block) => {
const key = `${block._type}-${block._key}`;
return (
<Fragment key={key}>
<div data-block-type={block._type} style={{ display: 'none' }} />
<BlockComponent block={block} useContainer={useContainer} />
</Fragment>
);
})}
</div>
</BlockContext>
);
}
2. Dynamic Block Component Registry
tsx
type ComponentProps<T extends Block> = {
block: T;
useContainer?: boolean;
};
type BlockComponent<T extends Block> = React.ComponentType<ComponentProps<T>>;
const blockComponents = {
hero: dynamic(() => import('./blocks/hero')),
text: dynamic(() => import('./blocks/text')),
media: dynamic(() => import('./blocks/media')),
gallery: dynamic(() => import('./blocks/gallery')),
form: dynamic(() => import('./blocks/form')),
// ... other block components
};
function BlockComponent(props: ComponentProps<Block>) {
const Component = blockComponents[props.block._type as keyof typeof blockComponents];
if (!Component) return null;
return <Component {...props} />;
}
export default memo(BlockComponent);
3. Individual Block Components
tsx
export default function TextBlock(props: ComponentProps<ITextBlock>) {
return (
<Container useContainer={props.useContainer}>
<Text {...props.block} />
</Container>
);
}
Type System
The type system ensures type safety across all blocks:
typescript
// Base block types
export type Block =
| IHeroBlock
| ITextBlock
| IMediaBlock
| IGalleryBlock
| IFormBlock;
// Utility types for working with blocks
export type BlockTypeMap = {
[B in Block as B['_type']]: B;
};
export type BlockByType<T extends Block['_type']> = BlockTypeMap[T];
export const isBlockType = <T extends Block['_type']>(
block: Block,
type: T
): block is BlockByType<T> => block._type === type;
export type BlockType = Block['_type'];
Why This Works Well
Tips
In Short
This pattern works well for content-heavy sites. It's maintainable, performs well, and scales nicely as your content model grows. Adapt it to your needs, but keep the core concepts of type safety and code splitting.
Special thanks to Nicklas for his valuable contributions in developing and refining this block-based rendering pattern.