Skip to content

Building custom sections

The portal ships a set of built-in section types — headings, prose, media, button, container, plus the brand-guide set (colors, icon list, do/don’t). A client repo can also define its own section types. A custom section is a first-class citizen: it appears in the editor’s insert menu, renders to static HTML for viewers by default (or hydrates as an island if it opts into interactive, the same mechanism built-ins use), hydrates as an editable component in the editor, and validates on save — all through the same registry the built-ins use.

A client repo registers custom sections through one file at the repo root: src/sections.ts (or .mjs / .js). It must default-export an array of section definitions:

src/sections.ts
import Callout from "./sections/Callout";
export default [Callout];

The framework’s Vite plugin exposes that file through a virtual module, virtual:portal/sections. When src/sections.ts exists, the plugin emits roughly:

import sections from "/abs/path/to/src/sections.ts";
import { registerSection } from "@drawnagency/primitives/lib/registry";
for (const def of sections) registerSection(def);
export default sections;

That virtual module is imported at every cold-start entry point — the viewer SSR page, the editor page, and the hydrated editor island (EditorWithMedia.tsx) — so your section is registered into the single global registry on every render path. There is no build step and no manual wiring: drop in src/sections.ts and it is live.

A definition needs a unique type, a label (and optionally an icon) for the picker, a Zod schema, a component, and a defaults() factory. Here is the smallest useful example — an editable one-line callout:

src/sections/Callout.tsx
import { defineSection } from "@drawnagency/primitives/lib/registry";
import { z } from "zod";
import { Megaphone } from "lucide-react";
import { EditablePlainText } from "@drawnagency/primitives/components/primitives";
const schema = z.object({
type: z.literal("callout"),
content: z.object({ text: z.string() }),
});
export default defineSection({
type: "callout", // unique key — namespace it to avoid clashing with built-ins
label: "Callout", // shown in the insert menu
icon: <Megaphone size={18} />, // any lucide-react icon (or ReactNode)
schema,
component: ({ content, onChange }) =>
onChange ? (
<EditablePlainText
tag="p"
className="callout"
value={content.content.text}
onChange={(text) => onChange({ ...content, content: { text } })}
isEditMode
/>
) : (
<p className="callout">{content.content.text}</p>
),
defaults: () => ({ type: "callout" as const, content: { text: "Heads up!" } }),
getLabel: (content) => content.content.text,
});

Then register it:

src/sections.ts
import Callout from "./sections/Callout";
export default [Callout];

Restart the dev server. “Callout” now appears in the insert menu — you can add it, edit its text inline, and save it (it validates against schema), and viewers receive a plain <p class="callout"> with zero JavaScript.

Every section component renders in two contexts from one definition:

  • Viewer (static by default): rendered server-side to static HTML with no runtime shipped for it, unless the section opts into interactive (hydrated as an island — see The registry & defineSection). onChange is undefined and isEditMode is false.
  • Editor (hydrated): the same component is rendered inside the editor island. onChange is provided and isEditMode is true.

The idiomatic pattern is to branch on onChange: render an editable primitive when it is present, plain markup when it is not. The editable primitives live in @drawnagency/primitives/components/primitives:

PrimitiveUse for
EditablePlainTextsingle-line / plain text (no formatting)
EditableRichTextTipTap rich text (bold, links, lists…)
MediaBlockan image from the media library

SectionProps<T> is the full prop contract the component receives (content, options, onChange, isEditMode, openModal). For every field a definition itself accepts — settings, settingsTabs, navRole, getThumbnails, inheritableSettings, and the rest — see The registry & defineSection.

If a field holds HTML, declare it in richTextFields. The framework sanitizes those fields server-side (at save and at SSR render), so the viewer branch can safely set the stored HTML. The apps/dev ProductCard shows the full pattern:

richTextFields: ["description"],
// editor branch:
<EditableRichText value={c.description} onChange={/* … */} isEditMode preset="rich" />
// viewer branch — richTextFields are sanitized by the framework:
<div dangerouslySetInnerHTML={{ __html: c.description }} />

Custom sections should style against the portal’s CSS custom properties so they follow each site’s settings: --color-primary / --color-primary-contrast / --color-on-primary, --font-heading / --font-body, and the corner-radius tokens --radius-outer (cards, plates, frames, buttons — driven by the site’s Corner radius setting) and --radius-inner (elements nested inside a padded outer-rounded container; derives as max(calc(var(--radius-outer) - 0.25rem), 0px)). For a nesting inset other than 0.25rem, compute your own concentric radius: calc(var(--radius-outer) - <inset>). Inline styles work fine (style={{ borderRadius: "var(--radius-outer)" }}) — no Tailwind compilation of site-local files required. Reserve rounded-full/999px for deliberately pill-shaped elements; those should not follow the token.

If your section paints a plate with background: var(--color-primary), take its foreground from --color-on-primary, never a hardcoded #fff:

// ✅ legible on every brand
color: "var(--color-on-primary)"
// muted secondary text / hairline rules — mix the ink toward the surface
color: "color-mix(in srgb, var(--color-on-primary) 70%, var(--color-primary))"
borderTop: "1px solid color-mix(in srgb, var(--color-on-primary) 25%, var(--color-primary))"

--color-on-primary is derived per brand (black or white, whichever wins WCAG on primaryColor), so the plate stays readable on a pastel primary as well as a deep one. A hardcoded white silently fails the moment a client picks a light brand colour.

Do not use --color-primary-contrast for body copy on such a plate. It is the accent, and brands legitimately set it to a decorative colour — Siete’s gold on aubergine reads as a highlight at 4.85:1 but would flatten a whole plate if it carried the body text. Keep it for kickers, numerals, active states and hover, which is exactly the hierarchy the two tokens are meant to express.

src/sections.ts and everything it imports is bundled into the hydrated editor island. That whole import graph must be browser-safe:

  • Allowed: @drawnagency/primitives/lib/registry (defineSection), @drawnagency/primitives/components/primitives, @drawnagency/primitives/schemas (e.g. LinkValueSchema, SingleMediaReferenceSchema, DEFAULT_LINK), zod, lucide-react, and your own pure React/CSS.
  • Forbidden: node:* modules, Buffer, server adapters (@drawnagency/github, @drawnagency/auth-supabase), filesystem access, or reading process.env directly. If you must read an env var, use the guarded env() helper from @drawnagency/primitives/lib/env.

A server-only import here breaks editor hydration — not the build — so it can pass astro build and still fail in the browser. Keep the module lean.

Section content is stored per-JSON-file under src/content/sections/. The two validation paths differ:

  • On load / SSR, mergeSiteContent() builds a z.union of every registered schema and safeParses each section file, dropping any that don’t match (with a console warning) so one bad file never breaks the page.
  • On save, /api/save validates each section with getSchema(type).safeParse() and rejects the whole save (HTTP 400) if any section is invalid.

Because the virtual:portal/sections channel runs at both the SSR and save entry points, your custom schema participates in both automatically — exactly like a built-in.

There is a third path, and it runs outside Vite: pnpm exec authoring validate --project .. The virtual module doesn’t exist there, so the CLI loads src/sections.{ts,mjs,js} itself — transpiling a TypeScript module with the esbuild that ships with Astro, then registering the definitions it default-exports. Your custom types are validated exactly like built-ins. Two consequences worth knowing:

  • The module is imported in Node, which is why the browser-safety rule above matters here too: a module-scope window reference or a node:*-hostile import makes it unloadable.
  • If it can’t be loaded, the CLI warns and skips those sections rather than failing them — it names the module, the reason, and the types it could not check. It never reports a site-local type as invalid content.

The custom-section channel is implemented, unit-tested, and exercised by a real build (apps/dev’s ProductCard). A few things to know before you rely on it:

  • src/sections.ts is the entry point — not the config field. The sections option in portal.config.mjs is typed but not read for registration.
  • Nothing is scaffolded. The template ships no src/sections.ts; you create it by hand. No provisioned *.drawn.guide client ships a custom section yet, so this path — while wired and tested — has not yet been run on a live client deploy.
  • Namespace your type. Registration is last-write-wins by type; a custom section whose type collides with a built-in silently replaces it. Use a distinctive key.
  • Reference implementation: apps/dev/src/sections/ProductCard.tsx + apps/dev/src/sections.ts (media + rich text + settings).