Skip to main content

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
  • zaf with group: 'assets' lets you batch-load asset URLs with getGroupsFromBlocks
  • SlugField.Root auto-generates slugs from another field (here, title)
  • RenderBlocks.Root handles iteration and component dispatch
  • SEO fields are a nested sub-schema, reusable across page types