Dyrecteddyrected
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', label: 'Heading',    type: 'text',         required: true },
    { name: 'subheading', label: 'Subheading', type: 'textarea' },
    { name: 'image', label: 'Image',      type: 'relationship', relationTo: 'media' },
    { name: 'ctaLabel', label: 'Cta Label',   type: 'text' },
    { name: 'ctaUrl', label: 'Cta Url',     type: 'url' },
  ],
}

const RichTextBlock = {
  slug: 'richText',
  labels: { singular: 'Rich Text', plural: 'Rich Text Blocks' },
  fields: [
    { name: 'content', label: 'Content', type: 'richText', required: true },
  ],
}

const CallToActionBlock = {
  slug: 'callToAction',
  labels: { singular: 'Call to Action', plural: 'Calls to Action' },
  fields: [
    { name: 'heading', label: 'Heading', type: 'text' },
    { name: 'body', label: 'Body',    type: 'textarea' },
    { name: 'label', label: 'Label',   type: 'text', required: true },
    { name: 'url', label: 'Url',     type: 'url',  required: true },
    { name: 'variant', label: 'Variant', type: 'select', options: ['primary', 'secondary'], defaultValue: 'primary' },
  ],
}

export default defineConfig({
  collections: [
    {
      slug: 'pages',
      access: { read: () => true },
      fields: [
        { name: 'title', label: 'Title',  type: 'text', required: true },
        { name: 'slug', label: 'Slug',   type: 'text', required: true },
        {
          name: 'layout',
          label: '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. Render the blocks

Instead of hand-writing a switch, use the Blocks renderer (<DyrectedBlocks> in Nuxt). You give it the layout array and a map of blockType → component; it renders each block in order. It also scopes each block's base path (layout.0, layout.1, …), which powers click-to-edit in live preview — so the same components work for both your public page and the preview.

// app/[slug]/page.tsx  — Server Component (fetch)
import { notFound } from 'next/navigation'
import { getDyrectedClient } from '@dyrected/next'
import PageBlocks from '@/components/page-blocks'

const client = getDyrectedClient()

export default async function Page({ params }: { params: Promise<{ slug: string }> }) {
  const { slug } = await params
  const { docs } = await client.collection('pages').find({
    where: { slug: { equals: slug } },
    limit: 1,
    depth: 1,
  })
  if (!docs[0]) notFound()

  return <main><PageBlocks layout={docs[0].layout} /></main>
}

export async function generateStaticParams() {
  const { docs } = await client.collection('pages').find({ limit: 100 })
  return docs.map((p: any) => ({ slug: p.slug }))
}
// components/page-blocks.tsx — Client Component (Blocks uses React context)
'use client'
import { Blocks } from '@dyrected/next'
import { Hero } from './blocks/Hero'
import { RichText } from './blocks/RichText'
import { CallToAction } from './blocks/CallToAction'

const components = { hero: Hero, richText: RichText, callToAction: CallToAction }

export default function PageBlocks({ layout }: { layout: any[] }) {
  return <Blocks items={layout} components={components} path="layout" />
}
<!-- pages/[slug].vue -->
<script setup lang="ts">
import { defineAsyncComponent } from 'vue'

const components = {
  hero: defineAsyncComponent(() => import('~/components/blocks/Hero.vue')),
  richText: defineAsyncComponent(() => import('~/components/blocks/RichText.vue')),
  callToAction: defineAsyncComponent(() => import('~/components/blocks/CallToAction.vue')),
}

const route = useRoute()
const { data: page } = await useDyrectedCollection('pages', {
  where: { slug: { equals: route.params.slug } },
  depth: 1,
  limit: 1,
})

const doc = computed(() => page.value?.docs?.[0])
if (!doc.value) throw createError({ statusCode: 404 })
</script>

<template>
  <!-- <DyrectedBlocks> is auto-imported by @dyrected/nuxt -->
  <main>
    <DyrectedBlocks :items="doc.layout" :components="components" path="layout" />
  </main>
</template>

4. The block components

Each component receives its block's fields as props. Annotate editable elements with useDyPath('<field>') — the base path (layout.<i>) is supplied by Blocks, so you only pass the field name. On the public page these attributes are inert; in the preview they become click-to-edit targets.

// components/blocks/Hero.tsx
import { useDyPath } from '@dyrected/next'

export function Hero({ heading, subheading, ctaLabel, ctaUrl, image }: any) {
  // Call useDyPath unconditionally at the top — it's a hook, so it can't sit
  // inside a conditional like `subheading && ...`.
  const dyHeading = useDyPath('heading')
  const dySubheading = useDyPath('subheading')
  const dyCtaLabel = useDyPath('ctaLabel')

  return (
    <section className="py-24 text-center">
      {image && <img src={image.url} alt={image.alt} className="mx-auto mb-8" />}
      <h1 {...dyHeading} className="text-5xl font-bold">{heading}</h1>
      {subheading && <p {...dySubheading} className="mt-4 text-xl">{subheading}</p>}
      {ctaLabel && (
        <a href={ctaUrl} {...dyCtaLabel} className="mt-8 inline-block rounded-md bg-black px-6 py-3 text-white">
          {ctaLabel}
        </a>
      )}
    </section>
  )
}
<!-- components/blocks/Hero.vue -->
<script setup lang="ts">
defineProps<{ heading: string; subheading?: string; image?: any; ctaLabel?: string; ctaUrl?: string }>()

// useDyPath is auto-imported by @dyrected/nuxt
const dyHeading = useDyPath('heading')
const dySubheading = useDyPath('subheading')
const dyCta = useDyPath('ctaLabel')
</script>

<template>
  <section class="py-24 text-center">
    <img v-if="image" :src="image.url" :alt="image.alt" class="mx-auto mb-8" />
    <h1 v-bind="dyHeading" class="text-5xl font-bold">{{ heading }}</h1>
    <p v-if="subheading" v-bind="dySubheading" class="mt-4 text-xl">{{ subheading }}</p>
    <a v-if="ctaLabel" :href="ctaUrl" v-bind="dyCta" class="mt-8 inline-block rounded-md bg-black px-6 py-3 text-white">
      {{ ctaLabel }}
    </a>
  </section>
</template>

Want editors to edit this page visually — typing and seeing changes live, or clicking an element to jump to its field? Follow Live Preview, which reuses these exact components.


Presentation variants

Sometimes one block should render in a few approved layouts — a Hero that is sometimes centered, sometimes split with an image, sometimes minimal. Rather than defining heroCentered, heroSplit, and heroMinimal as separate blocks (each duplicating the same fields), give one block a set of variants. All variants share the same fields; only the rendered layout differs.

Add a variants array to the block. This is a block-level property, not a field:

const HeroBlock = {
  slug: 'hero',
  labels: { singular: 'Hero', plural: 'Heroes' },
  variants: [
    { slug: 'centered', label: 'Centered', icon: 'AlignCenter', description: 'Title and subtitle centered, no image' },
    { slug: 'split', label: 'Split', icon: 'Columns2', description: 'Copy on the left, image on the right' },
    { slug: 'minimal', label: 'Minimal' },
  ],
  fields: [
    { name: 'heading', label: 'Heading', type: 'text', required: true },
    { name: 'subheading', label: 'Subheading', type: 'textarea' },
    { name: 'image', label: 'Image', type: 'relationship', relationTo: 'media' },
  ],
}

Each entry is a BlockVariant:

PropertyRequiredPurpose
slugYesStable identifier stored on the block row under the reserved variant key. Passed to your component as the variant prop. Never rename it once content exists, or saved rows lose their variant.
labelNoHuman-readable text shown in the Admin variant switcher. Falls back to slug.
iconNoA Lucide icon name shown beside the label in the switcher.
descriptionNoOne-line summary surfaced as the switcher's tooltip.

How editors choose a variant

When a block defines variants, the Admin editor shows a compact pill row labelled Variant at the top of that block, one pill per variant (with its icon and label). Selecting a pill updates the live preview immediately.

Switching a variant preserves the author's content. Because every variant shares one field set, changing the variant only rewrites the reserved variant key — every field value the editor already entered is kept.

The first variant is the default. New blocks start on variants[0], and any older rows saved before the block gained variants are initialised to variants[0].slug on load, so saving always round-trips a valid variant. You never define or manage a variant field yourself — it is reserved and handled for you.

What the API returns

The selected variant travels alongside blockType on each item:

{
  "layout": [
    { "blockType": "hero", "variant": "split", "heading": "Ship faster", "subheading": "…" }
  ]
}

Render the variant

<Blocks> (<DyrectedBlocks> in Nuxt) passes variant straight through to your component as a prop. Switch layout on it, and always fall back to a default so missing or unrecognised values still render safely.

// components/blocks/Hero.tsx
import { useDyPath } from '@dyrected/next'

export function Hero({ variant = 'centered', heading, subheading, image }: any) {
  const dyHeading = useDyPath('heading')
  const dySubheading = useDyPath('subheading')

  return (
    <section className={variant === 'split' ? 'grid md:grid-cols-2 gap-8' : 'py-24 text-center'}>
      <div>
        <h1 {...dyHeading} className="text-5xl font-bold">{heading}</h1>
        {variant !== 'minimal' && subheading && (
          <p {...dySubheading} className="mt-4 text-xl">{subheading}</p>
        )}
      </div>
      {variant === 'split' && image && <img src={image.url} alt={image.alt} />}
    </section>
  )
}
<!-- components/blocks/Hero.vue -->
<script setup lang="ts">
const props = withDefaults(
  defineProps<{ variant?: string; heading: string; subheading?: string; image?: any }>(),
  { variant: 'centered' },
)
const dyHeading = useDyPath('heading')
const dySubheading = useDyPath('subheading')
</script>

<template>
  <section :class="variant === 'split' ? 'grid md:grid-cols-2 gap-8' : 'py-24 text-center'">
    <div>
      <h1 v-bind="dyHeading" class="text-5xl font-bold">{{ heading }}</h1>
      <p v-if="variant !== 'minimal' && subheading" v-bind="dySubheading" class="mt-4 text-xl">
        {{ subheading }}
      </p>
    </div>
    <img v-if="variant === 'split' && image" :src="image.url" :alt="image.alt" />
  </section>
</template>

Only offer variants that already exist as approved designs, and only render variant slugs your component knows how to handle. Do not let editors type arbitrary variant names, and map each slug to a real layout rather than exposing styling implementation details.


Adding new block types

  1. Define a new block object and add it to the blocks array in your config.
  2. Create its component and annotate fields with useDyPath.
  3. Add it to the components map ({ myBlock: MyBlock }).

The Admin UI picks up new block types automatically — no switch statement to maintain.

On this page