I recently built a theme for EmDash and figured I'd document the process while it's fresh. If you've worked with Astro before, most of this will feel familiar. The main difference is how content flows from the CMS into your templates.
What Makes Up an EmDash Theme
An EmDash theme is really just a standard Astro project with a specific file structure. There's no theme API or config file — your theme *is* the site. Here's what you'll typically create:
- A base layout (
src/layouts/Base.astro) — the shell that wraps every page - Page templates (
src/pages/) — homepage, post detail, archive pages, search, etc. - Reusable components (
src/components/) — cards, tag lists, anything shared - A CSS file (
src/styles/theme.css) — your design tokens and global styles - A seed file (
seed/seed.json) — defines the content schema and demo data
Let's walk through each piece.
Step 1: Set Up the Design Tokens
Start with src/styles/theme.css. This is where you define your visual identity using CSS custom properties. EmDash doesn't impose any design system — you bring your own.
Here's a minimal starting point:
:root {
--color-bg: #060614;
--color-text: #f8fafc;
--color-muted: #94a3b8;
--color-accent: #00f0ff;
--font-sans: 'Inter', sans-serif;
--font-mono: 'JetBrains Mono', monospace;
--content-width: 720px;
--wide-width: 1200px;
--nav-height: 64px;
--radius: 8px;
--spacing-4: 1rem;
--spacing-6: 1.5rem;
--spacing-8: 2rem;
} You can go as deep as you want with this — shadows, glass effects, gradients, animations. The point is to centralize everything so you can tweak the entire feel of the site from one file.
Step 2: Build the Base Layout
The base layout is the most important file in your theme. It handles the HTML shell, navigation, footer, and wires up all the EmDash features.
Here's the skeleton:
---
import { getMenu, getSiteSettings } from "emdash";
import { EmDashHead, EmDashBodyStart, EmDashBodyEnd, WidgetArea } from "emdash/ui";
import LiveSearch from "emdash/ui/search";
import { createPublicPageContext } from "emdash/page";
import "../styles/theme.css";
const settings = await getSiteSettings();
const siteTitle = settings.title || "My Site";
const menu = await getMenu("primary");
const pageCtx = createPublicPageContext({ Astro, kind: "custom", pageType: "website", title: siteTitle });
---
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>{siteTitle}</title>
{settings.favicon?.url && <link rel="icon" href={settings.favicon.url} />}
<EmDashHead page={pageCtx} />
</head>
<body>
<EmDashBodyStart page={pageCtx} />
<header>
<nav>
<a href="/">{siteTitle}</a>
<LiveSearch placeholder="Search..." collections={["posts", "pages"]} />
{menu?.items.map(item => (
<a href={item.url} target={item.target}>{item.label}</a>
))}
</nav>
</header>
<main><slot /></main>
<footer>
<WidgetArea name="footer" />
</footer>
<EmDashBodyEnd page={pageCtx} />
</body>
</html> A few things worth noting:
EmDashHead,EmDashBodyStart, andEmDashBodyEndare required. They handle admin bar injection, visual editing, and other CMS features.createPublicPageContextgives plugins and the CMS the context they need about the current page.getSiteSettings()gives you access to the title, tagline, logo, and favicon that editors set in the admin panel.getMenu("primary")fetches a menu by name. The menu name must match what's in your seed file.LiveSearchis a built-in component that provides instant search across your content. You just need to style it.
Step 3: Create the Homepage
The homepage is where your theme's personality really shows. At minimum, you want to fetch posts and display them in a grid or list.
---
import { getEmDashCollection, getEntryTerms } from "emdash";
import { Image } from "emdash/ui";
import Base from "../layouts/Base.astro";
const { entries: posts, cacheHint } = await getEmDashCollection("posts");
Astro.cache.set(cacheHint);
---
<Base title="Home">
<div class="posts-grid">
{posts.map(post => (
<article>
{post.data.featured_image && (
<Image image={post.data.featured_image} />
)}
<h2><a href={`/posts/${post.id}`}>{post.data.title}</a></h2>
<p>{post.data.excerpt}</p>
</article>
))}
</div>
</Base> Two important things here:
- Always call
Astro.cache.set(cacheHint). This is how EmDash knows to invalidate the page cache when editors publish changes. Skip it and your homepage will show stale content. - Image fields are objects with
srcandalt, not strings. Use theImagecomponent fromemdash/uior accesspost.data.featured_image.srcdirectly.
Step 4: Build the Post Detail Page
Create src/pages/posts/[slug].astro. This is a dynamic route — EmDash serves content dynamically, so you never use getStaticPaths().
---
import { getEmDashEntry, getEntryTerms } from "emdash";
import { PortableText, Image, Comments, CommentForm } from "emdash/ui";
import Base from "../../layouts/Base.astro";
const { slug } = Astro.params;
const { entry: post, cacheHint } = await getEmDashEntry("posts", slug);
if (!post) return Astro.redirect("/404");
Astro.cache.set(cacheHint);
const tags = await getEntryTerms("posts", post.data.id, "tag");
---
<Base title={post.data.title}>
<article>
{post.data.featured_image && <Image image={post.data.featured_image} />}
<h1>{post.data.title}</h1>
<div class="content">
<PortableText value={post.data.content} />
</div>
<div class="tags">
{tags.map(t => <a href={`/tag/${t.slug}`}>{t.label}</a>)}
</div>
<Comments collection="posts" contentId={post.data.id} threaded />
<CommentForm collection="posts" contentId={post.data.id} />
</article>
</Base> Pay attention to the ID gotcha: post.id is the slug (for URLs), but post.data.id is the database ID (for API calls like getEntryTerms and Comments). Mixing them up gives you empty results with no error.
Step 5: Add Taxonomy Pages
If your schema has categories or tags, you'll want archive pages. Create src/pages/tag/[slug].astro:
---
import { getTerm, getEntriesByTerm } from "emdash";
import Base from "../../layouts/Base.astro";
const { slug } = Astro.params;
const term = await getTerm("tag", slug);
if (!term) return Astro.redirect("/404");
const { entries, cacheHint } = await getEntriesByTerm("tag", slug);
Astro.cache.set(cacheHint);
---
<Base title={term.label}>
<h1>{term.label}</h1>
{entries.map(post => (
<a href={`/posts/${post.id}`}>{post.data.title}</a>
))}
</Base> Important: the taxonomy name in API calls must match the name field in your seed exactly. If your seed says "name": "tag", you must use getTerm("tag", slug) — not "tags" or "Tag".
Step 6: Define the Schema in seed.json
Your seed file tells EmDash what content types exist, what fields they have, and what taxonomies are available. Here's a minimal example:
{
"$schema": "https://emdashcms.com/seed.schema.json",
"version": "1",
"settings": {
"title": "My Site",
"tagline": "A blog about things"
},
"collections": [
{
"slug": "posts",
"label": "Posts",
"supports": ["drafts", "revisions", "search"],
"fields": [
{ "slug": "title", "type": "string", "required": true },
{ "slug": "featured_image", "type": "image" },
{ "slug": "content", "type": "portableText" },
{ "slug": "excerpt", "type": "text" }
]
}
],
"taxonomies": [
{
"name": "tag",
"label": "Tags",
"collections": ["posts"]
}
],
"menus": [
{
"slug": "primary",
"label": "Primary Navigation",
"items": [
{ "label": "Home", "url": "/" },
{ "label": "Posts", "url": "/posts" }
]
}
]
} Validate it before running:
npx emdash seed seed/seed.json --validate Step 7: Style the Content
Portable Text renders semantic HTML — headings, paragraphs, blockquotes, code blocks, lists, links. You need to style all of these in your theme.
The trick is using Astro's :global() selector inside scoped styles, since the PortableText component generates its own markup:
.article-content :global(h2) {
font-size: 1.5rem;
margin-top: 2.5em;
}
.article-content :global(pre) {
padding: 1.25rem;
background: rgba(8, 8, 20, 0.7);
border-radius: 16px;
overflow-x: auto;
}
.article-content :global(code) {
font-family: var(--font-mono);
font-size: 0.9em;
}
.article-content :global(blockquote) {
border-left: 3px solid var(--color-accent);
padding-left: 1.5rem;
color: var(--color-muted);
} Don't forget pre code — you want to reset the inline code styling when it appears inside a code block.
Step 8: Wire Up Widget Areas and Search
EmDash has a widget system. Editors can add widgets (recent posts, categories, tag clouds, custom HTML) to named areas. In your theme, just drop in the WidgetArea component wherever you want widgets to appear:
<WidgetArea name="sidebar" />
<WidgetArea name="footer" /> For search, the built-in LiveSearch component handles everything — debounced input, results dropdown, keyboard navigation. You just style it using the CSS classes you pass in:
<LiveSearch
placeholder="Search..."
class="site-search"
inputClass="search-input"
resultsClass="search-results"
resultClass="search-result"
collections={["posts", "pages"]}
/> Common Gotchas
After building my theme, these are the things that tripped me up:
- No `getStaticPaths` — EmDash content is dynamic. All pages are server-rendered. Don't try to pre-build content pages.
- `entry.id` vs `entry.data.id` — The first is the slug, the second is the database ULID. Use the slug for URLs and the ULID for API calls. Getting this wrong gives you silent empty results.
- Image fields are objects —
post.data.featured_imageis{ src, alt }, not a string. Writing<img src={post.data.featured_image}>renders[object Object]. - Site settings logo uses `url`, not `src` —
settings.logoreturns{ mediaId, url, alt }which is different from entry image fields. Use a plain<img>tag instead of theImagecomponent. - Always set the cache hint — Every query returns a
cacheHint. CallAstro.cache.set(cacheHint)on every page or your content won't update when editors publish. - Taxonomy names are case-sensitive — Must match your seed file exactly.
Wrapping Up
Building an EmDash theme is basically building an Astro site with a few CMS-specific conventions. The content APIs are straightforward, the rendering is standard Astro, and the styling is entirely up to you. No theme framework, no template language, no restrictions.
The best way to learn is to look at an existing theme, modify it, break it, and see what happens. Run npx emdash dev and start experimenting.



