Skip to content

Section types

A section is a typed block of content owned by one page. Adding one means three things: an entry in your sections registry, a component, and a line in your template’s section map.

Sections are not a plugin extension point. They belong to the site, because the component that renders one is part of your design, not part of the engine.

Two halves, and they live in different places on purpose.

Half Where What it decides Who uses it
The entry: schema, form, ai descriptors, defaults your sections registry, or a shape from the engine what a section is the admin form, history, and the AI assistant
The component your template what a section looks like the visitor

The engine reads the entry to build the form, diff revisions, and tell the assistant which paths it may write. It never reads your component. Your template reads the value and renders it. It never reads the schema.

Some content has the same shape on every site. Those entries live in the engine as factories, so you compose one line instead of writing a schema, a form and a set of descriptors by hand. You still write the component, because how it looks is yours.

src/site/sections.ts
import { faqEntry } from '@ouncepage/core/faq';
export const sections = {
faq: faqEntry(),
};

Eight ship with Ounce. listSection is the general one, for any section that is a heading plus a repeating list of something; the rest are specific shapes built on it or beside it. Signatures and examples are in Entry factories.

Each takes optional title, blurb and defaults, so you can re-label one for your site without restating its fields:

faq: faqEntry({
title: 'Common questions',
blurb: 'Shown at the bottom of the memberships page.',
}),

faqEntry is worth reading as a worked example of a repeating section. It uses nothing special: a list field of text and richtext, the shared enabledField, and cta. A repeating section needs no new field type. list is the repeater, and the engine already strips items whose enabled is false before your component sees them.

This is the decision to make before you write the component, and getting it wrong is not obvious from the data.

  • One section per item. Each entry in the list becomes its own full width band down the page, with its own heading. Good for long-form content.
  • One section, a list inside. The whole list renders as a single compact block: an accordion, a grid, a table. Good for questions, features, people, prices.

The stored shape is identical. Only the component differs. If someone asks for “a repeater, not five sections” and your only repeating section renders the first way, the answer is a new section type that renders the second way, not a new field type.

  1. Define the entry.

    src/site/sections.ts
    import { z } from 'zod';
    import { entry, cta, required, text } from '@ouncepage/core';
    export const sections = {
    prose: entry({
    schema: z.object({
    heading: required,
    body: text.default(''),
    }),
    form: {
    title: 'Prose',
    blurb: 'A heading and a block of formatted text.',
    fields: [
    { name: 'heading', label: 'Heading', type: 'text' },
    { name: 'body', label: 'Body', type: 'richtext' },
    ],
    },
    defaults: {
    heading: 'Heading',
    body: '',
    },
    }),
    };
  2. Write the component.

    src/templates/v1/components/Prose.astro
    ---
    import type { SectionProps } from '@ouncepage/core';
    import type { Sections, Settings } from '../../../site/schema';
    const { data } = Astro.props as SectionProps<Sections['prose'], Settings>;
    ---
    <section id="prose" class="mx-auto max-w-2xl px-6 py-16">
    <h2 class="text-3xl font-semibold">{data.heading}</h2>
    <div class="prose mt-6" set:html={data.body} />
    </section>
  3. Register it for rendering.

    src/templates/v1/sections.ts
    import { defineSection } from '@ouncepage/core';
    import Prose from './components/Prose.astro';
    import Banner from './components/Banner.astro';
    export const sections = {
    prose: defineSection({ component: Prose }),
    banner: defineSection({ component: Banner, overlayHeader: true }),
    };
  4. Add it to a page with a migration, or let an editor add it in the admin if composeSections is on.

    migrations/0003_prose.sql
    INSERT INTO page_sections (page_id, key, position, data)
    SELECT id, 'prose', 2, json('{"heading":"About","body":""}')
    FROM pages WHERE slug = '/about';
interface SectionProps<Data, Settings> {
data: Data; // this section's validated value
settings: Settings; // every site setting
page: PageMeta; // slug, title, SEO of the page being rendered
index: number; // position in the page's section list
}

overlayHeader is a render hint, not content. It tells the template that this section sits under a transparent header. Nothing in the engine reads it; your layout does.

A section never reads another section’s data. If two sections need the same value, it is a setting. A call to action shared by three blocks belongs in site.cta, with each section falling back to it:

const button = data.button.link ? data.button : settings.site.cta;

A section component does not set its own anchor id. The engine’s Sections.astro already wraps each one in <div id="ounce-{key}">, and PRIMARY KEY (page_id, key) guarantees that id is unique on the page. Adding a second id inside the component gives you two targets for the same block, and a menu item generated from the section points at the wrapper, not at yours.

A repeating block is a list field, not two sections. The database will not let you put two prose sections on one page.

A section type says whether it belongs in a menu. nav: false on the entry means the section is never offered as a navigation target, which is right for a hero or a call to action. nav: { default: false } means it is navigable but starts out of the menu. Per page, an editor overrides either. See Navigation.

The engine ships the loop. Your template supplies the map from section key to component and hands both to it:

src/templates/v1/Page.astro
---
import Sections from '@ouncepage/core/Sections.astro';
import { sectionTypes } from './sections';
const { view } = Astro.props;
---
<Sections view={view} types={sectionTypes} />
src/templates/v1/sections.ts
import { defineSection } from '@ouncepage/core/sections';
import Prose from './components/Prose.astro';
export const sectionTypes = {
prose: defineSection({ component: Prose }),
};

It renders each key in view.keys, in stored order, wrapped in <div id="ounce-{key}" data-ounce-section="{key}">. That wrapper is what a menu item’s anchor points at and what a scrollspy watches, so a template that writes its own loop instead loses both. Each component receives { data, settings, page, index }.

The registry is a Partial<Record<...>> on purpose: a template may legitimately not implement every section type your schema defines, and skipping is better than crashing. It is also why a missing component is silent, so check a new section type renders before you call it done.

The compact form of a repeating section, as faqEntry expects to be rendered:

src/templates/v1/components/Faq.astro
<div class="divide-y border-t">
{faq.items.map((item, index) => (
<details name="faq" open={index === 0} class="group">
<summary class="flex cursor-pointer list-none items-start justify-between gap-4 py-5 [&::-webkit-details-marker]:hidden">
<h3>{item.question}</h3>
<span class="transition group-open:rotate-45" aria-hidden="true">+</span>
</summary>
{item.answer && <div class="prose pb-5" set:html={item.answer} />}
</details>
))}
</div>

Three things in there are load-bearing:

  • <summary> must be the first child of <details>. Wrap it in anything, even a <dt> for definition-list semantics, and the browser stops treating it as the label: it renders its own “Details” marker and drops your question into the body. It parses, it type checks, and it looks completely broken.
  • list-none and [&::-webkit-details-marker]:hidden remove the default triangle in every engine.
  • name on <details> makes the group exclusive, so opening one closes the rest, with no JavaScript.

No enabled filtering appears here, because the engine already removed disabled items on the way out.

  • Directorysrc/site
    • sections.ts the entries: schema, form, defaults
  • Directorysrc/templates/v1
    • sections.ts key to component
    • Sections.astro the loop
    • Directorycomponents/
      • Prose.astro

The split matters. src/site is content shape, which the admin reads. src/templates is presentation, which only the public site reads. Keeping them apart is what lets you build a second template against the same content.