Guides
Building a Page Builder
Use the blocks field to let editors assemble pages from reusable content blocks.
The blocks field lets editors pick from a set of block types and arrange them in any order — a hero, then a rich text section, then a call-to-action. The config is the same for both frameworks; only the rendering code differs.
1. Define your blocks
// dyrected.config.ts (same for both frameworks)
const HeroBlock = {
slug: 'hero',
labels: { singular: 'Hero', plural: 'Heroes' },
fields: [
{ name: 'heading', type: 'text', required: true },
{ name: 'subheading', type: 'textarea' },
{ name: 'image', type: 'relationship', relationTo: 'media' },
{ name: 'ctaLabel', type: 'text' },
{ name: 'ctaUrl', type: 'url' },
],
}
const RichTextBlock = {
slug: 'richText',
labels: { singular: 'Rich Text', plural: 'Rich Text Blocks' },
fields: [
{ name: 'content', type: 'richtext', required: true },
],
}
const CallToActionBlock = {
slug: 'callToAction',
labels: { singular: 'Call to Action', plural: 'Calls to Action' },
fields: [
{ name: 'heading', type: 'text' },
{ name: 'body', type: 'textarea' },
{ name: 'label', type: 'text', required: true },
{ name: 'url', type: 'url', required: true },
{ name: 'variant', type: 'select', options: ['primary', 'secondary'], defaultValue: 'primary' },
],
}
export default defineConfig({
collections: [
{
slug: 'pages',
access: { read: () => true },
fields: [
{ name: 'title', type: 'text', required: true },
{ name: 'slug', type: 'text', required: true },
{
name: 'layout',
type: 'blocks',
blocks: [HeroBlock, RichTextBlock, CallToActionBlock],
},
],
},
],
})2. What the API returns
Each item in layout includes a blockType matching the block's slug:
{
"layout": [
{ "blockType": "hero", "heading": "Ship content faster", "ctaLabel": "Get started", "ctaUrl": "/docs" },
{ "blockType": "richText", "content": { } },
{ "blockType": "callToAction", "label": "Start free", "url": "https://app.dyrected.com", "variant": "primary" }
]
}3. Fetch and render
// app/[slug]/page.tsx
import { notFound } from 'next/navigation'
import { createClient } from '@dyrected/sdk'
import { Block } from '@/components/Block'
const client = createClient({
baseUrl: process.env.NEXT_PUBLIC_DYRECTED_URL!,
apiKey: process.env.DYRECTED_API_KEY!,
})
async function getPage(slug: string) {
const { docs } = await client.collection('pages').find({
where: { slug: { equals: slug } },
depth: 1,
})
return docs[0] ?? null
}
export default async function Page({ params }: { params: { slug: string } }) {
const page = await getPage(params.slug)
if (!page) notFound()
return (
<main>
{page.layout.map((block: any, i: number) => (
<Block key={i} block={block} />
))}
</main>
)
}
export async function generateStaticParams() {
const { docs } = await client.collection('pages').find({ limit: 100 })
return docs.map((p: any) => ({ slug: p.slug }))
}<!-- pages/[slug].vue -->
<script setup lang="ts">
const route = useRoute()
const { data: page } = await useDyrectedFind('pages', {
where: { slug: { equals: route.params.slug } },
depth: 1,
limit: 1,
})
if (!page.value?.docs?.[0]) throw createError({ statusCode: 404 })
const blocks = computed(() => page.value.docs[0].layout)
</script>
<template>
<main>
<BlockRenderer v-for="(block, i) in blocks" :key="i" :block="block" />
</main>
</template>4. The block renderer
// components/Block.tsx
import { Hero } from './blocks/Hero'
import { RichText } from './blocks/RichText'
import { CallToAction } from './blocks/CallToAction'
export function Block({ block }: { block: any }) {
switch (block.blockType) {
case 'hero': return <Hero {...block} />
case 'richText': return <RichText {...block} />
case 'callToAction': return <CallToAction {...block} />
default: return null
}
}// components/blocks/Hero.tsx
export function Hero({ heading, subheading, ctaLabel, ctaUrl, image }: any) {
return (
<section className="py-24 text-center">
{image && <img src={image.url} alt={image.alt} className="mx-auto mb-8" />}
<h1 className="text-5xl font-bold">{heading}</h1>
{subheading && <p className="mt-4 text-xl">{subheading}</p>}
{ctaLabel && <a href={ctaUrl} className="mt-8 inline-block rounded-md bg-black px-6 py-3 text-white">{ctaLabel}</a>}
</section>
)
}<!-- components/BlockRenderer.vue -->
<script setup lang="ts">
defineProps<{ block: any }>()
</script>
<template>
<HeroBlock v-if="block.blockType === 'hero'" v-bind="block" />
<RichTextBlock v-else-if="block.blockType === 'richText'" v-bind="block" />
<CallToActionBlock v-else-if="block.blockType === 'callToAction'" v-bind="block" />
</template><!-- components/HeroBlock.vue -->
<script setup lang="ts">
defineProps<{ heading: string; subheading?: string; image?: any; ctaLabel?: string; ctaUrl?: string }>()
</script>
<template>
<section class="py-24 text-center">
<img v-if="image" :src="image.url" :alt="image.alt" class="mx-auto mb-8" />
<h1 class="text-5xl font-bold">{{ heading }}</h1>
<p v-if="subheading" class="mt-4 text-xl">{{ subheading }}</p>
<a v-if="ctaLabel" :href="ctaUrl" class="mt-8 inline-block rounded-md bg-black px-6 py-3 text-white">{{ ctaLabel }}</a>
</section>
</template>Adding new block types
- Define a new block object and add it to the
blocksarray in your config - Create its renderer component
- Add a case to the
Blockswitch (React) or av-else-if(Vue)
The Admin UI picks up new block types automatically.