Guides
Making a page builder
How to give a client a page they assemble themselves, from the sections you built. The recommended way to make a page editable.
For the developer. How to give a client a page they assemble themselves, from sections you built.
This is the recommended way to make a page editable, and it is what the app is
for. Everything here is ordinary Astro — defineCollection, a Zod schema, a
dynamic route. There is no plugin, no runtime, and nothing bespoke to install.
What it is
A page becomes a collection entry: a markdown file with some fields of its own, and an ordered array of blocks the client arranges.
---
title: Open Day
description: A Saturday of free taster classes.
blocks:
- type: pageHero
heading: Come and have a look
- type: featureGrid
heading: What is on
features:
- { title: Ten o'clock, description: A gentle hour. }
- type: callToAction
heading: The first Saturday in October
ctaLabel: Say you are coming
ctaHref: /contact
---
The client sees that as a stack of sections they can add to, reorder and edit. The same shape as a Sanity page-builder array, expressed in Astro’s own primitives, and stored as a file in the client’s repository.
Two halves, and only one of them is the app
This is the thing to understand before anything else, because the two are independent and each is useless without the other.
| Who does it | What it needs | |
|---|---|---|
| The client gets a page builder | The app | The discriminatedUnion in your schema |
| The page actually renders | The Astro build | A route file mapping type to a component |
The app never reads your route file to decide what to offer. It reads the schema, and only the schema. So:
- Write the union and forget the route, and your client gets a working page builder that renders nothing.
- Write the route and forget the union, and the page renders — but the client sees a plain form with no way to add a section.
Do both. There is no warning for the mismatch yet; it is on the roadmap.
Step 1 — the schema
The trigger is one construct: an array whose element is a
z.discriminatedUnion. Nothing else. No marker, no configuration file, no
particular collection name.
// src/content.config.ts
import { defineCollection, z } from 'astro:content'
import { glob } from 'astro/loaders'
const pages = defineCollection({
loader: glob({ pattern: '**/*.md', base: './src/content/pages' }),
schema: z.object({
// Fields before the array — the page's own settings.
title: z.string(),
description: z.string().optional(),
blocks: z.array(
z.discriminatedUnion('type', [
z.object({
type: z.literal('pageHero'),
label: z.string().optional(),
heading: z.string(),
sub: z.string().optional(),
picture: z.string().optional(),
variant: z.enum(['plain', 'tinted', 'photo']).default('tinted'),
}),
z.object({
type: z.literal('richText'),
heading: z.string().optional(),
body: z.string(),
}),
z.object({
type: z.literal('callToAction'),
heading: z.string(),
description: z.string(),
ctaLabel: z.string(),
ctaHref: z.string(),
}),
]),
).default([]),
// And fields after it, if you want them.
seo: z.object({ metaTitle: z.string().optional() }).optional(),
}),
})
export const collections = { pages }
What each part becomes
| In the schema | What the client sees |
|---|---|
Each z.literal('pageHero') |
A section named Page hero in the + menu |
| That member’s other fields | The form when the section is selected |
'type', the discriminator |
Nothing. It says what the block is; it is never shown or typed |
| Fields outside the array | The page’s settings, shown when no section is selected |
The name is made readable automatically: pageHero becomes Page hero,
callToAction becomes Call to action. Name the literals in camelCase and they
read correctly without any labelling.
Step 2 — the components
Ordinary Astro components, one folder each, with a typed interface Props.
Nothing about them is special — they do not need @client, because a block is
offered by the schema rather than by the palette.
---
// src/components/PageHero/PageHero.astro
import './PageHero.css';
interface Props {
/** A short line above the heading. */
label?: string;
heading: string;
sub?: string;
picture?: string;
variant?: 'plain' | 'tinted' | 'photo';
}
const { label, heading, sub, picture, variant = 'tinted' } = Astro.props;
---
Step 3 — the route
One file. A block’s type names a component, and its remaining keys are that
component’s props.
---
// src/pages/[...slug].astro
import { getCollection } from 'astro:content';
import Layout from '../layouts/Layout.astro';
import PageHero from '../components/PageHero/PageHero.astro';
import RichText from '../components/RichText/RichText.astro';
import CallToAction from '../components/CallToAction/CallToAction.astro';
export async function getStaticPaths() {
const pages = await getCollection('pages');
return pages.map((page) => ({ params: { slug: page.id }, props: { page } }));
}
const { page } = Astro.props;
const BLOCKS = {
pageHero: PageHero,
richText: RichText,
callToAction: CallToAction,
} as const;
---
<Layout title={page.data.seo?.metaTitle ?? page.data.title} description={page.data.description}>
{page.data.blocks.map((block) => {
const Block = BLOCKS[block.type];
const { type, ...props } = block;
return Block ? <Block {...props} /> : null;
})}
</Layout>
Adding a section to the site is two lines from here — a member in the union,
and a line in BLOCKS. The client finds it in the + menu the next time they
open the page.
Where the pages live
The route file’s location decides the address, and the app reads it — so the preview goes to the right place without being told.
| Route file | Entries served at | Use for |
|---|---|---|
src/pages/[...slug].astro |
/open-day |
Flex pages — the client’s own pages, anywhere |
src/pages/services/[slug].astro |
/services/coaching |
A typed section — the client makes more of one kind of thing |
Use [...slug] — the rest parameter — for flex pages, so a client can make
retreats/spain as well as open-day. Astro gives static routes priority over
a catch-all, so a flex page can never shadow about.astro or /blog.
A collection with no dynamic route has no page. The app knows, and shows the client its own panel instead of navigating somewhere that does not exist.
Blocks that read a collection
The most useful pattern, and the one that makes a page builder feel like one.
A block does not have to take its content as props. It can read a collection itself, so the client places “the questions” on a page and edits the questions in one place:
---
// src/components/FAQAccordion/FAQAccordion.astro
import { getCollection } from 'astro:content';
interface Props {
heading?: string;
/** Show only the first few. Leave empty for all of them. */
limit?: number;
}
const { heading = 'Questions', limit } = Astro.props;
const all = await getCollection('faqs');
const items = limit ? all.slice(0, limit) : all;
---
Its schema member is then only the presentation:
z.object({
type: z.literal('faqAccordion'),
heading: z.string().optional(),
limit: z.number().optional().describe('Leave empty to show every question.'),
})
The client never copies an answer between pages. Reviews, FAQs, team members and recent posts all work this way.
Giving the client some design control
A z.enum on a block is the cheapest way to let a client change the rhythm of a
page without touching CSS:
variant: z.enum(['plain', 'tinted']).default('plain'),
imageSide: z.enum(['left', 'right']).default('left'),
columns: z.number().default(3),
Up to three options that is a radio group; beyond it, a dropdown. The component turns the value into a class:
<section class={`split split--${variant} split--image-${imageSide}`}>
Keep the list short. Two or three looks that you have designed is a feature; eight is a decision the client did not want.
Fields you can put in a block
Everything the app understands works inside a block member:
z.string() |
One line — or a textarea, or the rich text editor, depending on the name |
z.number(), z.boolean() |
Number field, checkbox |
z.enum([…]) |
Radios or a dropdown |
z.array(z.string()) |
A repeater of single lines |
z.array(z.object({…})) |
A repeater of grouped fields — feature lists, link rows |
z.object({…}) |
A nested group |
reference('team') |
A dropdown of that collection’s entries |
.describe('…') |
Help text under the field |
.default(x) |
Pre-filled |
.optional() |
Not required |
Field names matter as much as types: a name ending url, href or link
gets a web address field with the site’s own pages suggested; one containing
description, summary or quote gets a multi-line box; one named exactly
body gets the rich text editor. See
Building a site for BooshCMS for the full list.
Traps worth knowing
A picture in a block is a plain string, not image(). Use
z.string().optional() and resolve it in the component with an eager
import.meta.glob. The same component is often used both on a page and inside a
collection entry, and those hand over different things — a project-relative path
and already-resolved metadata. One shape, resolved in the component, keeps it
working in both places. The client still gets the picture picker, because the
field is named picture.
Removing a block type does not remove it from existing entries. A page still
carrying type: carousel after you delete that member shows the client a
section marked “your website does not have this kind any more”, and leaves it
alone. Nothing is lost, and nothing is silently rewritten. Astro will also fail
the build on it, which is the right place to find out.
Two page-builder arrays in one schema is not a shape this models. The first one found wins.
The discriminator must be z.literal. z.enum(['pageHero']) is not a
discriminated union and will not be read as one.
A worked example
The demo site has two, and they are the best reference:
pages— flex pages at the root, rendered bysrc/pages/[...slug].astro. Twelve block types, four of which read a collection.services— the same twelve blocks under/services/, rendered bysrc/pages/services/[slug].astro.
Both share the same components and the same BLOCKS map, which is the usual
arrangement: one library of sections, used by however many kinds of page the
site has.
Checklist
- A collection whose schema has
z.array(z.discriminatedUnion('type', […])) - Every member starts with
type: z.literal('somethingCamelCase') - A component per member, with a typed
interface Props - A dynamic route with
getStaticPathsover that collection - A
BLOCKSmap in it naming every member -
[...slug].astroat the root for flex pages, or a folder for a typed section - Pictures as
z.string(), resolved in the component - One entry written by hand, to check it renders before the client sees it