Creating Themes
An EmDash theme is a complete Astro site — pages, layouts, components, styles — that also includes a seed file to bootstrap the content model. Build one to share your design with others, or to standardize site creation for your agency.
Key Concepts
Section titled “Key Concepts”- A theme is a working Astro project. There’s no theme API or abstraction layer. You build a site and ship it as a template. The seed file just tells EmDash what collections, fields, menus, redirects, and taxonomies to create on first run.
- EmDash gives you more control over the content model than WordPress. Themes take advantage of this — the seed file declares exactly what fields each collection needs. Build on the standard posts and pages collections and add fields and taxonomies as your design requires, rather than inventing entirely new content types.
- Content pages must be server-rendered. Content changes at runtime through the admin UI, so pages that display EmDash content cannot be prerendered. Never use
getStaticPaths()for EmDash content routes. - No hard-coded content. Site title, tagline, navigation, and other dynamic content come from the CMS via API calls — not from template strings.
Project Structure
Section titled “Project Structure”Create a theme with this structure:
my-emdash-theme/├── package.json # Theme metadata├── astro.config.mjs # Astro + EmDash configuration├── src/│ ├── live.config.ts # Live Collections setup│ ├── pages/│ │ ├── index.astro # Homepage│ │ ├── [...slug].astro # Pages (catch-all)│ │ ├── posts/│ │ │ ├── index.astro # Post archive│ │ │ └── [slug].astro # Single post│ │ ├── categories/│ │ │ └── [slug].astro # Category archive│ │ ├── tags/│ │ │ └── [slug].astro # Tag archive│ │ ├── search.astro # Search page│ │ └── 404.astro # Not found│ ├── layouts/│ │ └── Base.astro # Base layout│ └── components/ # Your components├── .emdash/│ ├── seed.json # Schema and sample content│ └── uploads/ # Optional local media files└── public/ # Static assetsPages live at the root as a catch-all route ([...slug].astro), so a page with slug about renders at /about. Posts, categories, and tags get their own directories. The .emdash/ directory contains the seed file and any local media files used in sample content.
Configuring package.json
Section titled “Configuring package.json”Add the emdash field to your package.json:
{ "name": "@your-org/emdash-theme-blog", "version": "1.0.0", "description": "A minimal blog theme for EmDash", "keywords": ["astro-template", "emdash", "blog"], "emdash": { "label": "Minimal Blog", "description": "A clean, minimal blog with posts, pages, and categories", "seed": ".emdash/seed.json", "preview": "https://your-theme-demo.pages.dev" }}| Field | Description |
|---|---|
emdash.label | Display name shown in theme pickers |
emdash.description | Brief description of the theme |
emdash.seed | Path to the seed file |
emdash.preview | URL to a live demo (optional) |
The Default Content Model
Section titled “The Default Content Model”Most themes need two collection types: posts and pages. Posts are timestamped entries with excerpts and featured images that appear in feeds and archives. Pages are standalone content at top-level URLs.
This is the recommended starting point. Add more collections, taxonomies, or fields as your theme needs them, but start here.
Seed File
Section titled “Seed File”The seed file tells EmDash what to create on first run. Create .emdash/seed.json:
{ "$schema": "https://emdashcms.com/seed.schema.json", "version": "1", "meta": { "name": "Minimal Blog", "description": "A clean blog with posts and pages", "author": "Your Name" }, "settings": { "title": "My Blog", "tagline": "Thoughts and ideas", "postsPerPage": 10 }, "collections": [ { "slug": "posts", "label": "Posts", "labelSingular": "Post", "supports": ["drafts", "revisions"], "fields": [ { "slug": "title", "label": "Title", "type": "string", "required": true }, { "slug": "content", "label": "Content", "type": "portableText" }, { "slug": "excerpt", "label": "Excerpt", "type": "text" }, { "slug": "featured_image", "label": "Featured Image", "type": "image" } ] }, { "slug": "pages", "label": "Pages", "labelSingular": "Page", "supports": ["drafts", "revisions"], "fields": [ { "slug": "title", "label": "Title", "type": "string", "required": true }, { "slug": "content", "label": "Content", "type": "portableText" } ] } ], "taxonomies": [ { "name": "category", "label": "Categories", "labelSingular": "Category", "hierarchical": true, "collections": ["posts"], "terms": [ { "slug": "news", "label": "News" }, { "slug": "tutorials", "label": "Tutorials" } ] } ], "menus": [ { "name": "primary", "label": "Primary Navigation", "items": [ { "type": "custom", "label": "Home", "url": "/" }, { "type": "custom", "label": "Blog", "url": "/posts" } ] } ], "redirects": [ { "source": "/category/news", "destination": "/categories/news" }, { "source": "/old-about", "destination": "/about" } ]}Posts get excerpt and featured_image because they appear in lists and feeds. Pages don’t need them — they’re standalone content. Add fields to either collection as your theme requires.
See Seed File Format for the complete specification, including sections, widget areas, and media references.
Building Pages
Section titled “Building Pages”All pages that display EmDash content are server-rendered. Use Astro.params to get the slug from the URL and query content at request time.
Homepage
Section titled “Homepage”---import { getEmDashCollection, getSiteSettings } from "emdash";import Base from "../layouts/Base.astro";
const settings = await getSiteSettings();const { entries: posts } = await getEmDashCollection("posts", { where: { status: "published" }, orderBy: { publishedAt: "desc" }, limit: settings.postsPerPage ?? 10,});---
<Base title="Home"> <h1>Latest Posts</h1> {posts.map((post) => ( <article> <h2><a href={`/posts/${post.slug}`}>{post.data.title}</a></h2> <p>{post.data.excerpt}</p> </article> ))}</Base>Single Post
Section titled “Single Post”---import { getEmDashEntry, getEntryTerms } from "emdash";import { PortableText } from "emdash/ui";import Base from "../../layouts/Base.astro";
const { slug } = Astro.params;const { entry: post } = await getEmDashEntry("posts", slug!);
if (!post) { return Astro.redirect("/404");}
const categories = await getEntryTerms("posts", post.id, "categories");---
<Base title={post.data.title}> <article> <h1>{post.data.title}</h1> <PortableText value={post.data.content} /> <div class="post-meta"> {categories.map((cat) => ( <a href={`/categories/${cat.slug}`}>{cat.label}</a> ))} </div> </article></Base>Pages use a catch-all route at the root so their slugs map directly to top-level URLs — a page with slug about renders at /about:
---import { getEmDashEntry } from "emdash";import { PortableText } from "emdash/ui";import Base from "../layouts/Base.astro";
const { slug } = Astro.params;const { entry: page } = await getEmDashEntry("pages", slug!);
if (!page) { return Astro.redirect("/404");}---
<Base title={page.data.title}> <article> <h1>{page.data.title}</h1> <PortableText value={page.data.content} /> </article></Base>Because this is a catch-all route, it only matches URLs that don’t have a more specific route. /posts/hello-world still hits posts/[slug].astro, not this file.
Category Archive
Section titled “Category Archive”---import { getTerm, getEntriesByTerm } from "emdash";import Base from "../../layouts/Base.astro";
const { slug } = Astro.params;const category = await getTerm("categories", slug!);const posts = await getEntriesByTerm("posts", "categories", slug!);
if (!category) { return Astro.redirect("/404");}---
<Base title={category.label}> <h1>{category.label}</h1> {posts.map((post) => ( <article> <h2><a href={`/posts/${post.slug}`}>{post.data.title}</a></h2> </article> ))}</Base>Using Images
Section titled “Using Images”Image fields are objects with src and alt properties, not strings. Use the Image component from emdash/ui for optimized image rendering:
---import { Image } from "emdash/ui";
const { post } = Astro.props;---
<article> {post.data.featured_image?.src && ( <Image image={post.data.featured_image} alt={post.data.featured_image.alt || post.data.title} width={800} height={450} /> )} <h2><a href={`/posts/${post.slug}`}>{post.data.title}</a></h2> <p>{post.data.excerpt}</p></article>Using Menus
Section titled “Using Menus”Query admin-defined menus in your layouts. Never hard-code navigation links:
---import { getMenu, getSiteSettings } from "emdash";
const settings = await getSiteSettings();const primaryMenu = await getMenu("primary");---
<html> <head> <title>{Astro.props.title} | {settings.title}</title> </head> <body> <header> {settings.logo ? ( <img src={settings.logo.url} alt={settings.title} /> ) : ( <span>{settings.title}</span> )} <nav> {primaryMenu?.items.map((item) => ( <a href={item.url}>{item.label}</a> ))} </nav> </header> <main> <slot /> </main> </body></html>Page Templates
Section titled “Page Templates”Themes often need multiple page layouts — a default layout, a full-width layout, a landing page layout. In EmDash, add a template select field to the pages collection and map it to layout components in your catch-all route.
Add the field to your pages collection in the seed file:
{ "slug": "template", "label": "Page Template", "type": "string", "widget": "select", "options": { "choices": [ { "value": "default", "label": "Default" }, { "value": "full-width", "label": "Full Width" }, { "value": "landing", "label": "Landing Page" } ] }, "defaultValue": "default"}Then map the value to layout components in the catch-all route:
---import { getEmDashEntry } from "emdash";import PageDefault from "../layouts/PageDefault.astro";import PageFullWidth from "../layouts/PageFullWidth.astro";import PageLanding from "../layouts/PageLanding.astro";
const { slug } = Astro.params;const { entry: page } = await getEmDashEntry("pages", slug!);
if (!page) { return Astro.redirect("/404");}
const layouts = { "default": PageDefault, "full-width": PageFullWidth, "landing": PageLanding,};const Layout = layouts[page.data.template as keyof typeof layouts] ?? PageDefault;---
<Layout page={page} />Editors choose the template from a dropdown in the admin UI when editing a page.
Adding Sections
Section titled “Adding Sections”Sections are reusable content blocks that editors can insert into any Portable Text field using the /section slash command. If your theme has common content patterns (hero banners, CTAs, feature grids), define them as sections in the seed file:
{ "sections": [ { "slug": "hero-centered", "title": "Centered Hero", "description": "Full-width hero with centered heading and CTA", "keywords": ["hero", "banner", "header", "landing"], "content": [ { "_type": "block", "style": "h1", "children": [{ "_type": "span", "text": "Welcome to Our Site" }] }, { "_type": "block", "children": [ { "_type": "span", "text": "Your compelling tagline goes here." } ] } ] }, { "slug": "newsletter-cta", "title": "Newsletter Signup", "keywords": ["newsletter", "subscribe", "email"], "content": [ { "_type": "block", "style": "h3", "children": [{ "_type": "span", "text": "Subscribe to our newsletter" }] }, { "_type": "block", "children": [ { "_type": "span", "text": "Get the latest updates delivered to your inbox." } ] } ] } ]}Sections created from the seed file are marked with source: "theme". Editors can also create their own sections (marked source: "user"), but theme-provided sections cannot be deleted from the admin UI.
Adding Sample Content
Section titled “Adding Sample Content”Include sample content in the seed file to demonstrate your theme’s design:
{ "content": { "posts": [ { "id": "hello-world", "slug": "hello-world", "status": "published", "data": { "title": "Hello World", "content": [ { "_type": "block", "style": "normal", "children": [{ "_type": "span", "text": "Welcome to your new blog!" }] } ], "excerpt": "Your first post on EmDash." }, "taxonomies": { "category": ["news"] } } ] }}Including Media
Section titled “Including Media”Reference images in sample content using the $media syntax.
For remote images:
{ "data": { "featured_image": { "$media": { "url": "https://images.unsplash.com/photo-xxx", "alt": "A descriptive alt text", "filename": "hero.jpg" } } }}For local images, place files in .emdash/uploads/ and reference them:
{ "data": { "featured_image": { "$media": { "file": "hero.jpg", "alt": "A descriptive alt text" } } }}During seeding, media files are downloaded (or read locally) and uploaded to storage.
Search
Section titled “Search”If your theme includes a search page, use the LiveSearch component for instant results:
---import LiveSearch from "emdash/ui/search";import Base from "../layouts/Base.astro";---
<Base title="Search"> <h1>Search</h1> <LiveSearch placeholder="Search posts and pages..." collections={["posts", "pages"]} /></Base>LiveSearch provides debounced instant search with prefix matching, Porter stemming, and highlighted result snippets. Search must be enabled per-collection in the admin UI (Content Types > Edit > Features > Search).
Testing Your Theme
Section titled “Testing Your Theme”-
Create a test project from your theme:
Terminal window npm create astro@latest -- --template ./path/to/my-theme -
Install dependencies and start the dev server:
Terminal window cd test-sitenpm installnpm run dev -
Complete the Setup Wizard at
http://localhost:4321/_emdash/admin -
Verify collections, menus, redirects, and content were created correctly
-
Test all page templates render properly
-
Create new content through the admin to verify all fields work
Publishing Your Theme
Section titled “Publishing Your Theme”Publish to npm for distribution:
npm publish --access publicUsers can then install your theme:
npm create astro@latest -- --template @your-org/emdash-theme-blogFor GitHub-hosted themes:
npm create astro@latest -- --template github:your-org/emdash-theme-blogCustom Portable Text Blocks
Section titled “Custom Portable Text Blocks”Themes can define custom Portable Text block types for specialized content. This is useful for marketing pages, landing pages, or any content that needs structured components beyond standard rich text.
Defining Custom Blocks in Seed Content
Section titled “Defining Custom Blocks in Seed Content”Use a namespaced _type in your seed file’s Portable Text content:
{ "content": { "pages": [ { "id": "home", "slug": "home", "status": "published", "data": { "title": "Home", "content": [ { "_type": "marketing.hero", "headline": "Build something amazing", "subheadline": "The all-in-one platform for modern teams.", "primaryCta": { "label": "Get Started", "url": "/signup" } }, { "_type": "marketing.features", "_key": "features", "headline": "Everything you need", "features": [ { "icon": "zap", "title": "Lightning fast", "description": "Built for speed." } ] } ] } } ] }}Creating Block Components
Section titled “Creating Block Components”Create Astro components for each custom block type:
---interface Props { value: { headline: string; subheadline?: string; primaryCta?: { label: string; url: string }; };}
const { value } = Astro.props;---
<section class="hero"> <h1>{value.headline}</h1> {value.subheadline && <p>{value.subheadline}</p>} {value.primaryCta && ( <a href={value.primaryCta.url} class="btn"> {value.primaryCta.label} </a> )}</section>Rendering Custom Blocks
Section titled “Rendering Custom Blocks”Pass your custom block components to the PortableText component:
---import { PortableText } from "emdash/ui";import Hero from "./blocks/Hero.astro";import Features from "./blocks/Features.astro";
interface Props { value: unknown[];}
const { value } = Astro.props;
const marketingTypes = { "marketing.hero": Hero, "marketing.features": Features,};---
<PortableText value={value} components={{ types: marketingTypes }} />Then use it in your pages:
---import { getEmDashEntry } from "emdash";import MarketingBlocks from "../components/MarketingBlocks.astro";
const { entry: page } = await getEmDashEntry("pages", "home");---
<MarketingBlocks value={page.data.content} />Anchor IDs for Navigation
Section titled “Anchor IDs for Navigation”Add _key to blocks that should be linkable:
{ "_type": "marketing.features", "_key": "features", "headline": "Features"}Then use it as an anchor in your component:
<section id={value._key}> <!-- content --></section>This enables navigation links like /#features.
Theme Checklist
Section titled “Theme Checklist”Before publishing, verify your theme includes:
-
package.jsonwithemdashfield (label, description, seed path) -
.emdash/seed.jsonwith valid schema - All collections referenced in pages exist in the seed
- Menus used in layouts are defined in the seed
- Sample content demonstrates the theme’s design
-
astro.config.mjswith database and storage configuration -
src/live.config.tswith EmDash loader - No
getStaticPaths()on content pages - No hard-coded site title, tagline, or navigation
- Image fields accessed as objects (
image.src), not strings - README with setup instructions
- Custom block components for any non-standard Portable Text types
Next Steps
Section titled “Next Steps”- Seed File Format — Complete reference for seed files
- Themes Overview — How themes work in EmDash
- Porting WordPress Themes — Convert existing WordPress themes