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 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.

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 (zero-JS): rendered server-side to static HTML. 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 }} />

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.

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).