Bitdoze logo

How to Build a Custom EmDash Theme: Tokens, Blocks, and Seed Schema

Part 3 of the EmDash series: every layer of a custom theme (design tokens, seed.json collections, block types, page routes, menus, widgets, and seeded content) with a real working example.

Dragos

47 min read

How to Build a Custom EmDash Theme: Tokens, Blocks, and Seed Schema

This is Part 3 of the EmDash series. Part 1 reviewed the CMS at 1.0 and Part 2 got a site running locally on Node.js and SQLite. This part is about the layer you actually own: the theme. It covers how an EmDash theme is put together and how to build your own: design tokens, the seed schema, block types, routes, menus, and seeded content.

The running example is real: emdashhq.com, the EmDash hub site, started from the stock marketing template and was restyled into a blueprint drawing-sheet look with six custom block types. Deploy is deliberately out of scope; the next part covers Cloudflare Workers, D1, and R2.

The finished custom theme: emdashhq.com home page with a blueprint grid background, spec-style hero, architecture diagram card, and tutorials index

Read Part 2: the tutorial

What an EmDash theme actually is

There is no theme store, no parent/child theme hierarchy, and no theme runtime. Per the official theme docs, an EmDash theme is a complete Astro project: routes, layouts, components, styles, and a seed/seed.json that declares the content model the templates expect. After scaffolding, every file is yours to edit directly.

That splits a custom theme into two halves:

  • The look: design tokens in src/styles/tokens.css, your overrides in src/styles/theme.css, fonts in astro.config.mjs, and one Astro component per page block.
  • The model: seed/seed.json declares collections, fields, block types, taxonomies, menus, widget areas, and sample content. The setup wizard applies it once, to an empty database.

Both halves live in your repo. Content lives in the database and is never in the repo.

Step 1: pick the right base template

create-emdash ships five templates, each with a Node.js and a Cloudflare variant. Start from the one closest to your target. You keep its routes and seed conventions instead of rebuilding them:

Template Content model Use it when
blank Built-in default seed You want zero opinions and will write everything
starter Basic pages A thin starting point for a custom design
blog Posts, pages, categories, tags, widgets A content site with a standard post archive
portfolio Projects + pages A showcase site
marketing Pages composed from versioned blocks Landing-style sites where editors assemble pages

The marketing template is the most instructive base: it ships the blocks field editor, five block types (hero, features, testimonials, pricing, FAQ), a catch-all page route, and the two-layer token system. Every block pattern below comes from it.

bash
npm create emdash@latest
# Project name: my-site
# Where will you deploy? Node.js     (swap later; the theme is adapter-agnostic)
# Which template? Marketing

Step 2: the theme file map

Before styling anything, know which file owns what:

File Owns
astro.config.mjs Adapter, integrations, emdash() options, fonts, icon include list
src/live.config.ts The _emdash live collection. Boilerplate; don’t touch
src/styles/tokens.css Design token defaults inside @layer base
src/styles/theme.css Your overrides. Unlayered, so they always win
seed/seed.json Content model + sample content + menus + settings
emdash-env.d.ts Generated types for every collection and block. Regenerate, never edit
src/layouts/Base.astro Document shell: settings, menus, <EmDashHead>, fonts, theme toggle
src/pages/[...slug].astro One route rendering every pages entry
src/components/blocks/*.astro One component per block type

package.json#emdash.seed points at the seed path; keep it if you move the file.

Step 3: re-skin with design tokens

The fastest rebrand touches one file. tokens.css declares every default inside @layer base; theme.css is unlayered, so a declaration there beats the default with no specificity games. Colors use light-dark(light, dark), which carries both modes in one token:

css
/* src/styles/theme.css */
:root {
	/* Paper and ink */
	--color-bg: light-dark(#f6f8fb, #0a192f);
	--color-text: light-dark(#0a1c38, #e2edfd);
	--color-muted: light-dark(#475c7e, #8ea6cc);
	--color-border: light-dark(#d3dfef, #1e3a66);
	--color-surface: light-dark(#ffffff, #0f274a);

	/* Brand */
	--color-brand: light-dark(#1d4ed8, #60a5fa);
	--color-brand-strong: light-dark(#1e40af, #3b82f6);
	--color-accent: light-dark(#0284c7, #22d3ee);
	--radius: 8px;
}

The token surface covers everything a theme needs:

Group Tokens
Colors --color-bg, --color-surface, --color-text, --color-muted, --color-border, --color-brand(-strong/-soft/-ring), --color-accent, --color-on-brand, --color-success/-warning/-danger
Gradients --gradient-brand(-strong/-soft), --gradient-headline; derive from brand colors, or use solid colors for a flat look
Typography --font-body, --font-heading, --font-mono, --font-weight-heading/-display, --font-size-xs through --font-size-6xl, --leading-*, --tracking-*
Layout --spacing-xs–--spacing-5xl, --max-width, --wide-width, --radius-*
Detail --line-hair, --line-ink, --transition-fast/-base/-slow, --shadow-sm through --shadow-xl

Two rules keep the dual-mode system honest. First, override a light-dark() token with light-dark(). A plain value repaints both modes at once, which is sometimes what you want and often not. Second, keep the @supports not (color: light-dark(#000,#fff)) fallback block in theme.css mirroring your light values for older browsers.

Fonts come from Astro’s font pipeline in astro.config.mjs, not from CSS imports:

js
fonts: [
	{
		provider: fontProviders.google(),
		name: "Archivo",
		cssVariable: "--font-body",
		weights: [400, 500, 600, 700, 800],
		fallbacks: ["system-ui", "sans-serif"],
	},
	{
		provider: fontProviders.google(),
		name: "IBM Plex Mono",
		cssVariable: "--font-mono",
		weights: [400, 500, 600],
		fallbacks: ["ui-monospace", "monospace"],
	},
],

The layout emits each face with <Font cssVariable="--font-body" preload /> from astro:assets, which handles the download, the CSS variable, and preloading. Swap the name, keep the variable name, and the whole site follows. Keep --font-heading pointing at --font-body for a single voice, or bind a second face for headings.

Step 4: declare the content model in seed.json

seed/seed.json is the schema your theme assumes. Top-level keys: $schema, version, meta, settings, blockTypes, collections, taxonomies, menus, widgetAreas, sections, bylines, content.

Collections

Each collection becomes an admin sidebar entry and a database table:

json
{
	"slug": "tutorials",
	"label": "Tutorials",
	"labelSingular": "Tutorial",
	"supports": ["drafts", "revisions", "search"],
	"group": "Content hub",
	"sortOrder": 10,
	"fields": [
		{ "slug": "title", "label": "Title", "type": "string", "required": true, "searchable": true },
		{ "slug": "summary", "label": "Summary", "type": "text", "searchable": true },
		{ "slug": "url", "label": "External URL", "type": "url", "required": true },
		{ "slug": "image", "label": "Cover", "type": "image" },
		{ "slug": "topic", "label": "Topic", "type": "select",
			"validation": { "options": ["install", "theme", "deploy", "plugins"] } },
		{ "slug": "featured", "label": "Featured", "type": "boolean", "defaultValue": false }
	]
}

Things worth knowing:

  • supports toggles workflows per collection: drafts, revisions, preview, scheduling, search, seo, plus commentsEnabled for the built-in comment system.
  • group folds related collections under one collapsible admin section; sortOrder controls sidebar order.
  • urlPattern (e.g. /posts/{slug}) defines permalinks for routable collections; match it to your Astro routes.
  • searchable: true on a field feeds the full-text index. Along with the search support, it’s required before LiveSearch returns anything.

Field types: string, text, url, number, integer, boolean, datetime, select, multiSelect, image, file, reference, slug, repeater, portableText, blocks, json. Two shapes matter most: image is an object { id, src?, alt?, width?, height? }, never a string, and blocks is the ordered page composition covered next.

Taxonomies, menus, widget areas, sections, settings

The smaller seed keys map to admin features:

  • taxonomies attach term systems to collections: { "name": "category", "hierarchical": true, "collections": ["posts"], "terms": [...] }. Query them with getTaxonomyTerms("category"). The name argument must match the seed’s name exactly, or you get empty results with no error.
  • menus seeds named menus (primary, header_cta, footer columns). Items are { "type": "custom", "label": "Tutorials", "url": "/tutorials/" }; editors reorder and extend them without code.
  • widgetAreas seeds named regions (like a sidebar) holding content (rich text), menu, or component widgets; core components include core:search, core:categories, core:tags, core:recent-posts, core:archives.
  • sections seeds reusable Portable Text snippets editors insert via the /section slash command. Handy for a repeated CTA or notice pattern.
  • settings seeds site identity: title, tagline, logo, favicon, social, timezone, dateFormat.

Step 5: block types, the page builder half of the theme

This is the part that makes EmDash feel like a page builder while staying code-driven. A blocks field on a collection stores an ordered list of typed blocks; each _type maps to one Astro component.

blocks field vs portableText

Use a blocks field when the value is a page-level composition: an ordered stack of sections an editor adds, removes, and reorders. Use portableText for a rich-text document (paragraphs, headings, lists, images) where custom objects live inside the flow, rendered via <PortableText components={{ type: customMap }} />. emdashhq’s pages collection is all blocks; a blog post body would be portableText.

json
{
	"slug": "content",
	"label": "Content",
	"type": "blocks",
	"validation": {
		"allowedTypes": ["marketing_hero", "marketing_features", "site_cta"],
		"maxItems": 30
	}
}

Declaring a block type

Block types live in blockTypes and are versioned. A real one, trimmed:

json
{
	"slug": "site_cta",
	"label": "Call to action",
	"description": "A banner with one or two buttons",
	"category": "Content hub",
	"currentVersion": 1,
	"versions": [
		{
			"version": 1,
			"fields": [
				{ "slug": "anchor_id", "label": "Anchor ID", "type": "string",
					"validation": { "pattern": "^(?:$|[a-z][a-z0-9_-]*)$" } },
				{ "slug": "headline", "label": "Headline", "type": "string", "required": true },
				{ "slug": "subheadline", "label": "Subheadline", "type": "text" },
				{ "slug": "primary_cta_label", "label": "Button label", "type": "string" },
				{ "slug": "primary_cta_url", "label": "Button URL", "type": "string" },
				{ "slug": "style", "label": "Style", "type": "select",
					"validation": { "options": ["gradient", "plain"] }, "defaultValue": "gradient" }
			]
		}
	]
}

The category groups blocks in the admin picker. Repeaters add structured rows inside a block; emdashhq’s hero uses one for its spec strip:

json
{
	"slug": "specs",
	"label": "Architecture highlights",
	"type": "repeater",
	"validation": {
		"maxItems": 4,
		"subFields": [
			{ "slug": "icon", "label": "Icon", "type": "select", "required": true,
				"options": ["zap", "database", "code", "cloud"] },
			{ "slug": "label", "label": "Label", "type": "string", "required": true }
		]
	}
}

Two hard limits to design around: block fields cannot contain references, json, slug, nested blocks, or nested repeaters, and a repeater cannot contain another repeater. Keep block schemas flat — if you need nesting, that’s a collection, not a block.

Versioning rules

Every stored block carries _type, _version, and a stable _key. When fields change:

  • Compatible changes (new optional field, label tweak) amend the active version in place.
  • Breaking changes (renaming or removing a field, changing its type) need a new entry in versions; the old version stays so existing stored blocks still render. Ship renderers that handle both before activating the new version.
  • Removing a type from allowedTypes moves it to a retiredTypes list rather than deleting stored data.
  • Adding a required blocks field to a collection that already has entries is not additive; backfill entries first.

Step 6: block components

Each block type maps to one Astro component. The wrapper the template ships (MarketingBlocks.astro on emdashhq) is a typed dictionary:

astro
---
import { Blocks, defineBlockComponents } from "emdash/ui";
import type { PageContentBlock } from "../../emdash-env";
import Hero from "./blocks/Hero.astro";
import SiteCta from "./blocks/SiteCta.astro";

const components = defineBlockComponents<PageContentBlock>({
	marketing_hero: Hero,
	site_cta: SiteCta,
	// ... one entry per allowed type
});
---

<Blocks value={Astro.props.value} components={components} />

defineBlockComponents<PageContentBlock> is typed off the generated union in emdash-env.d.ts. After editing the seed, restart the dev server and the union regenerates, so a missing or misspelled _type fails typecheck instead of failing silently. An unmapped block type renders a visible placeholder in dev and nothing (or your fallback component) in production.

A block component receives { value, index, blockKey }; value is the stored fields plus _type/_version/_key, typed by narrowing the union:

astro
---
// src/components/blocks/SiteCta.astro
import type { BlockComponentProps } from "emdash/ui";
import type { PageContentBlock } from "../../emdash-env";

type CtaBlock = Extract<PageContentBlock, { _type: "site_cta" }>;
type Props = BlockComponentProps<CtaBlock>;

const { value } = Astro.props;
---

<section class="section" id={value.anchor_id || undefined}>
	<h2>{value.headline}</h2>
	{value.subheadline && <p>{value.subheadline}</p>}
	{value.primary_cta_url && (
		<a href={value.primary_cta_url} class="btn btn-primary">
			{value.primary_cta_label}
		</a>
	)}
</section>

Blocks itself does no queries; it walks the stored array and calls components. But the components can query anything; they are ordinary Astro frontmatter:

astro
---
// inside a block component: cards fed by a collection
import { getEmDashCollection } from "emdash";

const { entries } = await getEmDashCollection("tutorials", {
	status: "published",
	orderBy: { published_at: "desc" },
	limit: 100,
});

let items = value.featured_only ? entries.filter((e) => e.data.featured) : entries;
if (value.limit > 0) items = items.slice(0, value.limit);
---

That pattern (collection in the seed, block options as fields, getEmDashCollection inside the component) is how every card section on emdashhq works. Shared partials (SectionHeader, FilterBar, SectionLink) keep section chrome consistent across blocks, and shared classes (.card-grid, .hub-card, .badge) in theme.css keep the look unified without a component library.

Step 7: routes and layout

One route for every page

The marketing template’s src/pages/[...slug].astro renders every pages entry: the slug is the URL, and a page seeded with slug home serves /:

astro
---
import { getEmDashEntry, getSeoMeta } from "emdash";
import MarketingBlocks from "../components/MarketingBlocks.astro";
import Base from "../layouts/Base.astro";

const requested = Astro.params.slug;
if (requested === "home") return Astro.redirect("/", 301);

const slug = requested ?? "home";
const { entry: page, cacheHint } = await getEmDashEntry("pages", slug);

if (Astro.cache?.enabled) Astro.cache.set(cacheHint);
if (!page && slug !== "home") return Astro.rewrite("/404");

const seo = page && getSeoMeta(page, {
	siteUrl: Astro.url.origin,
	path: slug === "home" ? "/" : `/${slug}/`,
});
---

<Base title={seo?.title} description={seo?.description} image={seo?.ogImage}
	canonical={seo?.canonical} robots={seo?.robots}
	content={page ? { collection: "pages", id: page.data.id, slug: page.id } : undefined}
>
	<div {...page?.edit.content}>
		<MarketingBlocks value={page?.data.content} />
	</div>
</Base>

New pages need no code: create an entry in the admin, get a URL. Other collections get their own routes the same way (getEmDashCollection for archives, getEmDashEntry for detail pages).

The layout wires in the admin-managed chrome

Base.astro is where the theme meets the admin. The load-bearing pieces:

astro
---
import { getMenuWithCacheHint, getSiteSettingsWithCacheHint } from "emdash";
import { EmDashBodyEnd, EmDashBodyStart, EmDashHead } from "emdash/ui";
import { createPublicPageContext } from "emdash/page";
import { Font } from "astro:assets";

const [settings, primary, headerCta, footerLearn] = await Promise.all([
	getSiteSettingsWithCacheHint(),
	getMenuWithCacheHint("primary"),
	getMenuWithCacheHint("header_cta"),
	getMenuWithCacheHint("footer_learn"),
]);
if (Astro.cache?.enabled)
	[settings, primary, headerCta, footerLearn].forEach((r) => Astro.cache.set(r.cacheHint));

const pageCtx = createPublicPageContext({ Astro, kind: "content", pageType: "website", /* title, description, image, content */ });
---
<head>
	<Font cssVariable="--font-body" preload />
	<Font cssVariable="--font-mono" preload />
	<EmDashHead page={pageCtx} />
</head>
<body>
	<EmDashBodyStart page={pageCtx} />
	<!-- header, nav from getMenu(), slot, footer -->
	<EmDashBodyEnd page={pageCtx} />
</body>

Three things this buys you:

  • Admin-managed identity and navigation. getSiteSettings() returns title, tagline, logo, favicon, social URLs; getMenu("primary") returns items editors control (each with label, resolved url, target, children for dropdowns). Editors change the nav without a deploy.
  • Plugin page contributions. EmDashHead/EmDashBodyStart/EmDashBodyEnd render whatever plugins contribute to those slots: the site-scripts plugin’s analytics snippets on emdashhq, for example. Leave them in even with no plugins installed; adding one later needs no layout change.
  • Automatic SEO overlay. On server-rendered pages that fetch an entry and render <EmDashHead> with a content context, the SEO panel’s description, image, canonical, and noindex apply with no extra query. <EmDashHead> can’t set <title>; that’s what getSeoMeta() is for.

Click-to-edit

Every entry carries an edit proxy. Spread entry.edit.<field> onto the element rendering that field and a logged-in admin gets click-to-edit on the public page:

astro
<h1 {...post.edit.title}>{post.data.title}</h1>
<div {...page.edit.content}><MarketingBlocks value={page.data.content} /></div>

Cheap to add, invisible to visitors, and it makes the theme feel integrated instead of bolted on.

Step 8: seed content so the theme demos itself

A theme that installs empty is a theme nobody evaluates. The content key seeds entries per collection; the home page seeds its blocks as literal values:

json
"content": {
	"pages": [
		{
			"id": "home",
			"slug": "home",
			"status": "published",
			"data": {
				"title": "Home",
				"content": [
					{
						"_type": "marketing_hero",
						"_version": 1,
						"_key": "home-hero",
						"headline": "EmDash tutorials, themes and plugins in one place",
						"specs": [{ "icon": "zap", "label": "Cloudflare Workers SSR" }]
					},
					{ "_type": "site_tutorials", "_version": 1, "_key": "home-tutorials",
						"headline": "Latest tutorials", "limit": 3 }
				]
			}
		}
	]
}

Seed-content rules that save debugging time:

  • Give every block and span a stable _key; it’s required, and duplicated keys produce broken editing behavior.
  • Image fields take { "$media": { "url": "...", "alt": "...", "filename": "..." } }. EmDash downloads and stores the file. A bare URL string skips the download but renders.
  • Reference other seeded entries with "$ref:seed-id", never raw IDs.
  • "status": "draft" seeds unpublished content for testing draft views.
  • npx emdash export-seed --with-content snapshots a working site back into a seed file. The fastest way to author content is to build the pages in the admin, then export.

Seed is applied once, on first request to an empty database before setup completes; it never overwrites existing data. Schema changes to a live database go through schema evolution, not re-seeding.

Step 9: the conventions that keep a theme coherent

Small things, but they are where DIY themes drift:

  • Icons. Keep a fixed icon vocabulary. On emdashhq a select field’s options map through src/lib/icons.ts (ICON_MAP) to Phosphor names, and every icon is also listed in the astro-iconset include config. That’s three places per new icon: seed options, the map, the build list. Miss the build list and the icon silently doesn’t ship.
  • Stored URLs. Anything an editor can type into a url/string field goes through sanitizeHref() before href. A linkAttrs() helper wraps it and adds target="_blank" rel="noopener noreferrer" to absolute web links.
  • Images. Always <Image image={post.data.featured_image} /> from emdash/ui. <img src={field}> on an object renders [object Object], the single most common EmDash template bug.
  • Search. LiveSearch from emdash/ui/search drops into the header; it only indexes collections with the search support and fields flagged searchable, and it’s skinned with --emdash-search-* CSS variables so it matches the theme.
  • Dark mode. The cookie-toggle pattern: a tiny is:inline script in <head> adds .light/.dark (or nothing = follow OS) before paint, and light-dark() tokens do the rest. On emdashhq, html:not(.light):not(.dark) { color-scheme: light dark } overrides the admin stylesheet’s color-scheme: light pin in system mode. Copy that fix or dark mode will stick light.

The five mistakes every new EmDash theme makes

Image fields rendered as strings (<img src={field}>; use <Image>). Querying a taxonomy by the plural name ("categories" when the seed says "category" gives empty results with no error). Passing entry.id (the slug) where data.id (the ULID) is required, as in getEntryTerms and Comments. getStaticPaths on CMS routes: content is dynamic, so stay on output: "server". And forgetting Astro.cache.set(cacheHint) on cached routes, which makes publish events not invalidate the page.

Verify the theme works

  • npm run dev regenerates emdash-env.d.ts with your new collection and block types
  • The admin block picker shows your block types under their categories, with the fields you declared
  • A page composed of seeded blocks renders on /; a new admin-created page works with no code change
  • Menus, site title, and social links edited in the admin appear on the public site
  • Click-to-edit works for a logged-in admin on rendered fields
  • Both color modes render; no hard-coded light-only colors in components
  • /definitely-missing hits your 404 route, not a blank page

Distributing the theme

A finished theme is just a repo. To make it scaffoldable like the official ones, keep package.json#emdash.seed set, keep the template paths (src/pages, src/layouts, src/components, seed/), and someone else can run npm create astro@latest -- --template your-org/your-repo. The official @emdash-cms/template-* packages on npm work the same way. What ships is the code plus the seed; content always starts empty in the new owner’s database.

FAQ

blocks field or portableText — which one do I use?

Use blocks for page-level composition: an ordered stack of typed sections (hero, features, CTA) that editors add and reorder. Use portableText for document content: a post body, an about page, anything paragraph-flow. Custom objects inside rich text render through the PortableText component’s components.type map and need a plugin for a custom editor UI; page-level blocks get their editing UI from the seed declaration alone.

Can I change a block's fields after content uses it?

Yes, carefully. Adding optional fields is compatible; amend the active version. Renaming, removing, or retyping a field is breaking: add a new entry to versions, keep the old version so stored blocks still render, and update your component to handle both shapes before activating it. The generated PageContentBlock union keeps every retained version typed.

Does the theme ever touch my content?

No. The theme is repo code plus seed/seed.json. The seed applies once to an empty database; content lives in D1/SQLite and media in R2/disk after that. Redeploying or editing theme files never overwrites entries, and editors’ changes never touch your repo.

Next: ship it

You now have the theme surface end to end: tokens and fonts for the look, seed.json for the model, block types for the editor experience, routes and a layout for the wiring, and seeded content for the first-run impression. To see every pattern in this article running in production, browse emdashhq.com, where the blueprint tokens, the custom block types in the page editor, and the single catch-all route are all live.

The only thing left is infrastructure: Part 4 deploys this exact stack to Cloudflare Workers, D1, and R2.