Skip to content

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.

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

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 assets

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

Add the emdash field to your package.json:

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"
}
}
FieldDescription
emdash.labelDisplay name shown in theme pickers
emdash.descriptionBrief description of the theme
emdash.seedPath to the seed file
emdash.previewURL to a live demo (optional)

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.

The seed file tells EmDash what to create on first run. Create .emdash/seed.json:

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

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.

src/pages/index.astro
---
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>
src/pages/posts/[slug].astro
---
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:

src/pages/[...slug].astro
---
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.

src/pages/categories/[slug].astro
---
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>

Image fields are objects with src and alt properties, not strings. Use the Image component from emdash/ui for optimized image rendering:

src/components/PostCard.astro
---
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>

Query admin-defined menus in your layouts. Never hard-code navigation links:

src/layouts/Base.astro
---
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>

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:

src/pages/[...slug].astro
---
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.

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:

.emdash/seed.json
{
"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.

Include sample content in the seed file to demonstrate your theme’s design:

.emdash/seed.json
{
"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"]
}
}
]
}
}

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.

If your theme includes a search page, use the LiveSearch component for instant results:

src/pages/search.astro
---
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).

  1. Create a test project from your theme:

    Terminal window
    npm create astro@latest -- --template ./path/to/my-theme
  2. Install dependencies and start the dev server:

    Terminal window
    cd test-site
    npm install
    npm run dev
  3. Complete the Setup Wizard at http://localhost:4321/_emdash/admin

  4. Verify collections, menus, redirects, and content were created correctly

  5. Test all page templates render properly

  6. Create new content through the admin to verify all fields work

Publish to npm for distribution:

Terminal window
npm publish --access public

Users can then install your theme:

Terminal window
npm create astro@latest -- --template @your-org/emdash-theme-blog

For GitHub-hosted themes:

Terminal window
npm create astro@latest -- --template github:your-org/emdash-theme-blog

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.

Use a namespaced _type in your seed file’s Portable Text content:

.emdash/seed.json
{
"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."
}
]
}
]
}
}
]
}
}

Create Astro components for each custom block type:

src/components/blocks/Hero.astro
---
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>

Pass your custom block components to the PortableText component:

src/components/MarketingBlocks.astro
---
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:

src/pages/index.astro
---
import { getEmDashEntry } from "emdash";
import MarketingBlocks from "../components/MarketingBlocks.astro";
const { entry: page } = await getEmDashEntry("pages", "home");
---
<MarketingBlocks value={page.data.content} />

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.

Before publishing, verify your theme includes:

  • package.json with emdash field (label, description, seed path)
  • .emdash/seed.json with 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.mjs with database and storage configuration
  • src/live.config.ts with 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