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 as zero-JavaScript HTML for viewers, hydrates as an editable component in the editor, and validates on save — all through the same registry the built-ins use.
How registration works
Section titled “How registration works”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:
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 minimal custom section
Section titled “A minimal custom section”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:
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:
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.
The viewer / editor split
Section titled “The viewer / editor split”Every section component renders in two contexts from one definition:
- Viewer (zero-JS): rendered server-side to static HTML.
onChangeisundefinedandisEditModeisfalse. - Editor (hydrated): the same component is rendered inside the editor island.
onChangeis provided andisEditModeistrue.
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:
| Primitive | Use for |
|---|---|
EditablePlainText | single-line / plain text (no formatting) |
EditableRichText | TipTap rich text (bold, links, lists…) |
MediaBlock | an 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.
Rich text & sanitization
Section titled “Rich text & sanitization”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 }} />Browser-safety (hard requirement)
Section titled “Browser-safety (hard requirement)”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 readingprocess.envdirectly. If you must read an env var, use the guardedenv()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.
How content validates
Section titled “How content validates”Section content is stored per-JSON-file under src/content/sections/. The two validation paths differ:
- On load / SSR,
mergeSiteContent()builds az.unionof every registered schema andsafeParses each section file, dropping any that don’t match (with a console warning) so one bad file never breaks the page. - On save,
/api/savevalidates each section withgetSchema(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.
Current status
Section titled “Current status”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.tsis the entry point — not the config field. Thesectionsoption inportal.config.mjsis typed but not read for registration.- Nothing is scaffolded. The template ships no
src/sections.ts; you create it by hand. No provisioned*.drawn.guideclient 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 bytype; a custom section whosetypecollides 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).