Dyrected
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

  1. Define a new block object and add it to the blocks array in your config
  2. Create its renderer component
  3. Add a case to the Block switch (React) or a v-else-if (Vue)

The Admin UI picks up new block types automatically.

On this page