Building a Landing Page
End-to-end example: define blocks, wire up a form, and render the page.
1. Define the button schema
Reusable sub-schema for CTA buttons:
// blocks/schemas.ts
import { zaf } from '@repo/form'
import { z } from 'zod'
export const buttonSchema = z.object({
label: z.string(),
url: z.string(),
})
2. Define blocks
// blocks/index.ts
import { RenderBlocks, RichTextEditor } from '@repo/cms'
import { FormField, zaf } from '@repo/form'
import { z } from 'zod'
import { buttonSchema } from './schemas'
import Hero from './Hero.svelte'
import ContentSection from './ContentSection.svelte'
export const landingPageBlocks = {
hero: {
label: 'Hero',
schema: z.object({
title: zaf(z.string(), { component: FormField.Textarea }),
description: zaf(RichTextEditor.schema, { component: RichTextEditor.Root }),
image: zaf(z.string(), {
component: AssetAutocompleteField,
group: 'assets',
}),
buttons: z.array(buttonSchema),
}),
component: RenderBlocks.renderComponent(Hero, {}),
},
content: {
label: 'Content Section',
schema: z.object({
body: zaf(RichTextEditor.schema, { component: RichTextEditor.Root }),
}),
component: RenderBlocks.renderComponent(ContentSection, {}),
},
}
3. Create the Hero component
<!-- blocks/Hero.svelte -->
<script lang="ts">
import type { JSONContent } from '@tiptap/core'
type Props = {
data: {
title: string
description: JSONContent
image: string
buttons: { label: string; url: string }[]
}
}
const { data }: Props = $props()
</script>
<section class="py-20 text-center">
<h1 class="text-4xl font-bold">{data.title}</h1>
<div class="mt-6 flex justify-center gap-4">
{#each data.buttons as btn}
<a href={btn.url} class="px-6 py-3 bg-primary text-white rounded-lg">
{btn.label}
</a>
{/each}
</div>
</section>
4. Build the page form schema
// page.remote.ts
import { BlockField, SlugField, getSeoInsertSchema } from '@repo/cms'
import { FormField, zaf } from '@repo/form'
import { form } from '$app/server'
import { z } from 'zod'
import { landingPageBlocks } from './blocks'
const seoSchema = getSeoInsertSchema({
assetToUrl: (id) => `/api/assets/${id}`,
assetDataKey: 'asset',
})
const pageSchema = z.object({
title: z.string(),
slug: zaf(z.string(), {
component: FormField.renderComponent(SlugField.Root, { basedOn: 'title' }),
}),
seo: seoSchema,
blocks: zaf(z.array(z.any()), {
component: FormField.renderComponent(BlockField.Root, { blocks: landingPageBlocks }),
}),
})
export const createPage = form(pageSchema, async (data) => {
// save to database
})
5. Edit page
<!-- +page.svelte -->
<script lang="ts">
import { TypedForm } from '@repo/form'
import { createPage, pageSchema } from './page.remote'
</script>
<TypedForm.Remote form={createPage} schema={pageSchema} />
6. Render the landing page
<!-- landing/+page.svelte -->
<script lang="ts">
import { RenderBlocks, getGroupsFromBlocks } from '@repo/cms'
import { landingPageBlocks } from './blocks'
import { getPage } from './page.remote'
const page = await getPage({ slug: 'home' })
// Batch-load asset URLs
const groups = getGroupsFromBlocks({ value: page.blocks, blocks: landingPageBlocks })
const assetUrls = await loadAssetUrls(groups.assets)
</script>
<svelte:head>
<title>{page.seo.title}</title>
<meta name="description" content={page.seo.description} />
</svelte:head>
<RenderBlocks.Root blocks={landingPageBlocks} value={page.blocks} />
Key takeaways
- Each block is self-contained: schema defines fields, component defines rendering
zafwithgroup: 'assets'lets you batch-load asset URLs withgetGroupsFromBlocksSlugField.Rootauto-generates slugs from another field (here,title)RenderBlocks.Roothandles iteration and component dispatch- SEO fields are a nested sub-schema, reusable across page types