Skip to content
On this pageProject Structure
  1. Project Structure
  2. The Base Layout
  3. Tailwind 4 and Design Tokens
  4. Rendering Portable Text
  5. The Image Component
  6. Dynamic Site Data

Create a Custom EmDash Theme from Scratch

Ben 3 min read

When I started building with EmDash, I expected a theme system — some special format, a theme API, maybe a marketplace. There's none of that. EmDash themes are just Astro projects. That's actually the right call.

You get the full Astro ecosystem: components, layouts, integrations, TypeScript, Tailwind, whatever you want. There's no theme sandbox to fight against. This guide walks through building a theme from a blank Astro project to a fully functional EmDash-powered site.

EmDash themes are standard Astro projects. No special format, no theme API, no restrictions.

Project Structure

An EmDash theme is an Astro project with a few required files alongside your own layouts, pages, and components. Here's the structure I use:

FilePurpose
astro.config.mjsAstro config with emdash() integration and Tailwind vite plugin
src/live.config.tsEmDash loader registration — boilerplate, do not modify
src/layouts/Base.astroBase layout with EmDashHead, nav, and footer
src/pages/Astro pages — all server-rendered, no getStaticPaths for CMS content
src/styles/global.cssTailwind 4 config with design tokens in @theme
seed/seed.jsonSchema definition and demo content — the source of truth for collections

The Base Layout

Every page inherits from a base layout. The critical piece is EmDashHead — it handles all SEO meta, title, canonical URL, Open Graph tags, and JSON-LD structured data. Do not add a manual <title> or <meta name="description"> tag. EmDashHead outputs them already and duplicates will hurt you in search.

EmDashBodyStart and EmDashBodyEnd wrap the page body. These inject the scripts EmDash needs for live preview, analytics hooks, and plugin contributions. Always include both.

src/layouts/Base.astro
---
import { EmDashHead, EmDashBodyStart, EmDashBodyEnd } from 'emdash/ui';
import { getSiteSettings, getMenu } from 'emdash';
import { createPublicPageContext } from 'emdash/page';

interface Props {
  pageContext: ReturnType<typeof createPublicPageContext>;
}

const { pageContext } = Astro.props;
const settings = await getSiteSettings();
const { menu: mainNav } = await getMenu('main');
---
<!doctype html>
<html lang="en">
  <head>
    <meta charset="utf-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1" />
    <EmDashHead pageContext={pageContext} />
  </head>
  <body>
    <EmDashBodyStart />
    <header>
      <nav aria-label="Main navigation">
        <a href="/" aria-label={`${settings.title} home`}>{settings.title}</a>
        <ul>
          {mainNav?.items.map(item => (
            <li><a href={item.url}>{item.label}</a></li>
          ))}
        </ul>
      </nav>
    </header>
    <main>
      <slot />
    </main>
    <footer>
      <p>&copy; {new Date().getFullYear()} {settings.title}</p>
    </footer>
    <EmDashBodyEnd />
  </body>
</html>

Tailwind 4 and Design Tokens

EmDash uses Tailwind 4 via the @tailwindcss/vite plugin — not the PostCSS plugin from v3. The configuration moves from tailwind.config.js into your CSS file using the @theme directive. This is where you define your design tokens as CSS custom properties.

Define semantic color names rather than raw values. Using text-heading instead of text-gray-900 means dark mode and rebranding are a one-line change in your CSS, not a find-and-replace across every component.

src/styles/global.css
@import 'tailwindcss';

@theme {
  /* Typography */
  --font-sans: 'Inter', system-ui, sans-serif;
  --font-mono: 'JetBrains Mono', monospace;

  /* Semantic colors — light mode */
  --color-heading: oklch(15% 0 0);
  --color-body: oklch(30% 0 0);
  --color-muted: oklch(55% 0 0);
  --color-surface: oklch(98% 0 0);
  --color-surface-raised: oklch(100% 0 0);
  --color-border: oklch(90% 0 0);
  --color-accent: oklch(55% 0.18 250);
  --color-accent-hover: oklch(48% 0.18 250);
}

@media (prefers-color-scheme: dark) {
  @theme {
    --color-heading: oklch(95% 0 0);
    --color-body: oklch(80% 0 0);
    --color-muted: oklch(60% 0 0);
    --color-surface: oklch(12% 0 0);
    --color-surface-raised: oklch(18% 0 0);
    --color-border: oklch(25% 0 0);
  }
}

Rendering Portable Text

EmDash rich text fields store content as Portable Text — a structured JSON format. The PortableText component from emdash/ui handles standard blocks out of the box: paragraphs, headings, lists, links, strong, em, and code.

For custom block types — pullquotes, tables, code blocks, image embeds — pass a components prop. Each key maps a _type string to an Astro component. When EmDash encounters that block type in the content stream, it renders your component with the block data as props.

The Image Component

Image fields in EmDash are objects, not strings. The field value is { id, src, alt, width, height } — writing src={post.data.featured_image} renders [object Object]. Always use the Image component from emdash/ui, which accepts the full image object and handles responsive sizing, lazy loading, and format optimization.

Dynamic Site Data

Pull the site title, description, and logo from getSiteSettings() rather than hardcoding them. Editors can update site settings through the admin panel without touching code. getMenu() fetches navigation menus by slug — define your menu structure in seed.json and query it by name in your layout.

This separation matters at scale. When the site name changes or a new menu item gets added, the change happens in the admin panel and propagates everywhere immediately — no deploy needed.

Deploy your theme →