MonoStack
Guides

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

On this page