---
title: Creating Themes
description: Build and distribute your own EmDash themes.
---

import { Aside, Steps, Tabs, TabItem } from "@astrojs/starlight/components";

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

- **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.
- **Theme content pages must be server-rendered.** In a theme, content changes at runtime through the admin UI, so pages that display EmDash content must not be prerendered. Do not use `getStaticPaths()` in theme content routes. (Static site builds using EmDash as a build-time data source _can_ use `getStaticPaths`, but themes are always SSR.)
- **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

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.

## Configuring package.json

Add the `emdash` field to your `package.json`:

```json title="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

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

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

```json title=".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](/themes/seed-files/) for the complete specification, including sections, widget areas, and media references.

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

<Aside type="caution">
	In themes, never use `getStaticPaths()` or `export const prerender = true` for pages that display
	EmDash content. Themes serve content at runtime through the admin UI, so these pages must be
	server-rendered.
</Aside>

### Homepage

```astro title="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>
```

### Single Post

```astro title="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

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`:

```astro title="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.

### Category Archive

```astro title="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>
```

## Using Images

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

```astro title="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>
```

<Aside type="caution">
	A common mistake is treating image fields as strings. `post.data.featured_image` is an object
	with `src` and `alt` -- writing `<img src={post.data.featured_image} />` renders `[object Object]`.
</Aside>

## Using Menus

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

```astro title="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>
```

## 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:

```json
{
	"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:

```astro title="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.

## 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:

```json title=".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.

## Adding Sample Content

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

```json title=".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"]
				}
			}
		]
	}
}
```

<Aside type="tip">
	Sample content is optional during setup. Users can uncheck "Include sample content" in the Setup
	Wizard if they want a clean start.
</Aside>

## Including Media

Reference images in sample content using the `$media` syntax.

For remote images:

```json
{
	"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:

```json
{
	"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

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

```astro title="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).

## Testing Your Theme

1. Create a test project from your theme:

   ```bash
   npm create astro@latest -- --template ./path/to/my-theme
   ```

2. Install dependencies and start the dev server:

   ```bash
   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

## Publishing Your Theme

Publish to npm for distribution:

```bash
npm publish --access public
```

Users can then install your theme:

```bash
npm create astro@latest -- --template @your-org/emdash-theme-blog
```

For GitHub-hosted themes:

```bash
npm create astro@latest -- --template github:your-org/emdash-theme-blog
```

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

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

```json title=".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."
								}
							]
						}
					]
				}
			}
		]
	}
}
```

### Creating Block Components

Create Astro components for each custom block type:

```astro title="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>
```

### Rendering Custom Blocks

Pass your custom block components to the `PortableText` component:

```astro title="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:

```astro title="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} />
```

<Aside>
  Custom block types don't have admin UI editors by default. Users can edit the seeded content
  through the standard Portable Text editor or modify the JSON directly. For a full admin editing
  experience, consider creating a plugin with custom editor components.
</Aside>

### Anchor IDs for Navigation

Add `_key` to blocks that should be linkable:

```json
{
	"_type": "marketing.features",
	"_key": "features",
	"headline": "Features"
}
```

Then use it as an anchor in your component:

```astro
<section id={value._key}>
  <!-- content -->
</section>
```

This enables navigation links like `/#features`.

## Theme Checklist

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

## Next Steps

- **[Seed File Format](/themes/seed-files/)** -- Complete reference for seed files
- **[Themes Overview](/themes/overview/)** -- How themes work in EmDash
- **[Porting WordPress Themes](/themes/porting-wp-themes/)** -- Convert existing WordPress themes
