# Create a Theme

> Build a reusable EmDash Astro template with server-rendered routes and an initial seed.

Source: https://emdashcmseverything.com/official-docs/themes/creating-themes/

An EmDash theme is an Astro project that another developer can scaffold with `create-astro`. Build and test the project as a complete site, then include a seed that creates the content model expected by its routes and components.

## Start from a current template

Choose the existing template closest to the intended site and deployment target. The Node and Cloudflare variants keep their database, storage, adapter, and middleware configuration together.

The blog template is a useful base for a content site:

```bash
npm create astro@latest -- --template @emdash-cms/template-blog
```

Use `@emdash-cms/template-blog-cloudflare` when the resulting theme targets Cloudflare Workers, D1, and R2.

## Keep the template structure

The current blog template uses these relevant paths:

```text title="Template structure"
astro.config.mjs
emdash-env.d.ts
package.json
seed/
└── seed.json
src/
├── components/
│   └── PostCard.astro
├── layouts/
│   └── Base.astro
├── live.config.ts
├── pages/
│   ├── index.astro
│   ├── category/[slug].astro
│   ├── pages/[slug].astro
│   ├── posts/index.astro
│   ├── posts/[slug].astro
│   ├── search.astro
│   └── tag/[slug].astro
└── styles/
```

The starter, portfolio, and marketing templates use different routes. Copy actual files from the chosen base instead of assuming every theme has a catch-all page route.

## Point to the seed

Current templates declare their seed path in `package.json`:

```json title="package.json"
{
  "name": "@example/emdash-theme-publication",
  "private": true,
  "type": "module",
  "emdash": {
    "seed": "seed/seed.json"
  }
}
```

EmDash also discovers `.emdash/seed.json` and the conventional `seed/seed.json` fallback. Use the package field when distributing a template so the intended file is explicit.

## Define the content model

When you start from the blog template, edit its existing `seed/seed.json` rather than replacing it with an unrelated model. The following reduced seed keeps the collections and structural data used by the examples in this guide:

```json title="seed/seed.json"
{
  "$schema": "https://emdashcms.com/seed.schema.json",
  "version": "1",
  "meta": {
    "name": "Publication",
    "description": "A publication with posts"
  },
  "settings": {
    "title": "Publication",
    "tagline": "Latest articles"
  },
  "collections": [
    {
      "slug": "posts",
      "label": "Posts",
      "labelSingular": "Post",
      "supports": ["drafts", "revisions", "search", "seo"],
      "fields": [
        {
          "slug": "title",
          "label": "Title",
          "type": "string",
          "required": true,
          "searchable": true
        },
        {
          "slug": "excerpt",
          "label": "Excerpt",
          "type": "text"
        },
        {
          "slug": "featured_image",
          "label": "Featured image",
          "type": "image"
        },
        {
          "slug": "content",
          "label": "Content",
          "type": "portableText",
          "searchable": true
        }
      ]
    },
    {
      "slug": "pages",
      "label": "Pages",
      "labelSingular": "Page",
      "supports": ["drafts", "revisions", "search"],
      "fields": [
        {
          "slug": "title",
          "label": "Title",
          "type": "string",
          "required": true,
          "searchable": true
        },
        {
          "slug": "content",
          "label": "Content",
          "type": "portableText",
          "searchable": true
        },
        {
          "slug": "template",
          "label": "Page template",
          "type": "select",
          "defaultValue": "default",
          "validation": {
            "options": ["default", "full-width", "landing"]
          }
        }
      ]
    }
  ],
  "menus": [
    {
      "name": "primary",
      "label": "Primary navigation",
      "items": [
        { "type": "custom", "label": "Home", "url": "/" },
        { "type": "custom", "label": "Posts", "url": "/posts" }
      ]
    }
  ],
  "widgetAreas": [
    {
      "name": "sidebar",
      "label": "Sidebar",
      "widgets": []
    }
  ],
  "content": {
    "posts": [
      {
        "id": "post-welcome",
        "slug": "welcome",
        "status": "published",
        "data": {
          "title": "Welcome",
          "excerpt": "The first article",
          "content": []
        }
      }
    ]
  }
}
```

The content entry `id` is a seed-local identifier used by references. It is not forced to become the database ID for a routable entry. Its `slug` becomes the route identifier exposed as `entry.id` by the query API.

Read [Seed files](https://emdashcmseverything.com/official-docs/themes/seed-files/) before adding taxonomies, bylines, menu references, media, redirects, widget areas, sections, localization, or conflict behavior.

## Build server-rendered routes

Current EmDash templates use `output: "server"`. Routes query live content for each request. Do not add `getStaticPaths()` to a theme's content routes unless the template deliberately uses EmDash only as a build-time source.

The following archive orders in the database using the stored `published_at` field:

```astro title="src/pages/posts/index.astro"
---
import { getEmDashCollection } from "emdash";
import Base from "../../layouts/Base.astro";

const { entries: posts, error, cacheHint } = await getEmDashCollection("posts", {
  orderBy: { published_at: "desc" },
});

if (error) return new Response("Could not load posts", { status: 500 });
if (Astro.cache?.enabled) Astro.cache.set(cacheHint);
---

<Base title="Posts">
  {posts.map((post) => (
    <article>
      <h2><a href={`/posts/${post.id}`}>{post.data.title}</a></h2>
      {post.data.excerpt && <p>{post.data.excerpt}</p>}
    </article>
  ))}
</Base>
```

`orderBy` is a field-to-direction object. Use `orderBy: { published_at: "desc" }`, not `sort`, `sortBy`, or a JavaScript callback.

The following route resolves and renders one post:

```astro title="src/pages/posts/[slug].astro"
---
import { decodeSlug, getEmDashEntry } from "emdash";
import { PortableText } from "emdash/ui";
import Base from "../../layouts/Base.astro";

const slug = decodeSlug(Astro.params.slug);
if (!slug) return Astro.rewrite("/404");

const { entry: post, error, cacheHint } = await getEmDashEntry("posts", slug);
if (error) return new Response("Could not load the post", { status: 500 });
if (!post) return Astro.rewrite("/404");
if (Astro.cache?.enabled) Astro.cache.set(cacheHint);
---

<Base title={post.data.title} content={{ collection: "posts", id: post.data.id, slug }}>
  <article>
    <h1 {...post.edit.title}>{post.data.title}</h1>
    <PortableText value={post.data.content} />
  </article>
</Base>
```

Use `post.id` in route URLs and `post.data.id` where a helper requires the stored content ID.

## Query site-managed navigation

CMS-managed values should come from the matching APIs. The current templates use `getSiteSettings()`, `getMenu()`, and `<WidgetArea />` in their layouts:

```astro title="src/layouts/Base.astro"
---
import { getMenu, getSiteSettings } from "emdash";
import { WidgetArea } from "emdash/ui";

const [settings, primary] = await Promise.all([
  getSiteSettings(),
  getMenu("primary"),
]);
---

<header>
  <a href="/">{settings.title}</a>
  <nav>
    {primary?.items.map((item) => <a href={item.url}>{item.label}</a>)}
  </nav>
</header>

<main><slot /></main>
<aside><WidgetArea name="sidebar" /></aside>
```

Static design copy can remain in Astro files. Values that administrators are expected to edit must be represented in settings, content, menus, or widgets.

## Render image fields

Image fields are media values, not URL strings. Pass the complete value to the `Image` component so local storage and image providers resolve consistently:

```astro title="src/components/PostCard.astro"
---
import { Image } from "emdash/ui";

const { post } = Astro.props;
---

<article>
  {post.data.featured_image && (
    <Image
      image={post.data.featured_image}
      alt={post.data.title}
      width={800}
      height={450}
    />
  )}
  <h2><a href={`/posts/${post.id}`}>{post.data.title}</a></h2>
</article>
```

The component uses the field's own alt text unless you override it. Reserve `priority` for an image that is expected above the fold; other images remain lazy-loaded.

## Offer page layout choices

The starter seed above includes a `select` field for sites where editors need more than one page layout. When adding the feature to another seed, use stable values that map to known components:

```json
{
  "slug": "template",
  "label": "Page template",
  "type": "select",
  "defaultValue": "default",
  "validation": {
    "options": ["default", "full-width", "landing"]
  }
}
```

The route can then select from an explicit component map:

```astro title="src/pages/pages/[slug].astro"
---
import { decodeSlug, getEmDashEntry } from "emdash";
import PageDefault from "../../layouts/PageDefault.astro";
import PageFullWidth from "../../layouts/PageFullWidth.astro";
import PageLanding from "../../layouts/PageLanding.astro";

const slug = decodeSlug(Astro.params.slug);
if (!slug) return Astro.rewrite("/404");

const { entry: page } = await getEmDashEntry("pages", slug);
if (!page) return Astro.rewrite("/404");

const layouts = {
  default: PageDefault,
  "full-width": PageFullWidth,
  landing: PageLanding,
};
const Layout = layouts[page.data.template as keyof typeof layouts] ?? PageDefault;
---

<Layout {page} />
```

The explicit map keeps a stored field value from becoming an arbitrary module path.

## Add search

Enable `search` in each collection that should appear in results, and mark the relevant fields as `searchable`. The current templates use `LiveSearch` for a ready-made search route:

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

On an Astro i18n site, `LiveSearch` uses `Astro.currentLocale`. Pass `locale={null}` only when the page intentionally searches every locale.

## Seed reusable sections

Sections give editors reusable Portable Text starting points. Add them when the design includes a repeated content pattern such as a call to action:

```json title="seed/seed.json"
{
  "version": "1",
  "sections": [
    {
      "slug": "newsletter-signup",
      "title": "Newsletter signup",
      "description": "Heading and copy for the newsletter form",
      "keywords": ["newsletter", "email"],
      "content": [
        {
          "_type": "block",
          "_key": "newsletter-heading",
          "style": "h2",
          "children": [
            { "_type": "span", "_key": "newsletter-heading-text", "text": "Get new articles by email" }
          ]
        }
      ]
    }
  ]
}
```

The setup wizard lets a user omit seeded entries, bylines, and taxonomy terms. Sections and the rest of the structural model are still applied.

## Add structured page blocks

Use a [`blocks` field](https://emdashcmseverything.com/official-docs/guides/blocks/) when the page itself is an ordered composition of typed sections. Use a custom Portable Text block when the object belongs inside a rich-text document and participates in its paragraph flow.

The marketing template seeds eleven retained block types and allows them in the Pages `content` field. Its wrapper maps every generated `_type` to an Astro component:

```astro title="src/components/MarketingBlocks.astro"
---
import { Blocks, defineBlockComponents } from "emdash/ui";
import type { PageContentBlock } from "../../emdash-env";
import CTA from "./blocks/CTA.astro";
import Contact from "./blocks/Contact.astro";
import FAQ from "./blocks/FAQ.astro";
import Features from "./blocks/Features.astro";
import Hero from "./blocks/Hero.astro";
import Logos from "./blocks/Logos.astro";
import Pricing from "./blocks/Pricing.astro";
import Split from "./blocks/Split.astro";
import Stats from "./blocks/Stats.astro";
import Testimonials from "./blocks/Testimonials.astro";
import Text from "./blocks/Text.astro";

const components = defineBlockComponents<PageContentBlock>({
  marketing_hero: Hero,
  marketing_features: Features,
  marketing_testimonials: Testimonials,
  marketing_pricing: Pricing,
  marketing_faq: FAQ,
  marketing_logos: Logos,
  marketing_split: Split,
  marketing_stats: Stats,
  marketing_cta: CTA,
  marketing_contact: Contact,
  marketing_text: Text,
});
---

<Blocks value={Astro.props.value} components={components} />
```

The generated `PageContentBlock` union keeps the component map aligned with every allowed block type and retained version. Each seeded block value includes `_type`, `_version`, and a stable `_key`.

Use a custom Portable Text block for an object that belongs between paragraphs, such as an embedded diagram in an article. Map that object through the `PortableText` component's `components.type` option. A seed can include the stored value, but a native plugin must register a custom editor for creating and editing that Portable Text object.

Use a native plugin when the block needs a reusable custom editor and packaged rendering components. Sandboxed plugins cannot ship Astro rendering components into a site build.

## Test the template

1. Scaffold the template into a clean directory with `create-astro`.

2. Install dependencies and run the site's build and typecheck commands.

3. Start with an empty database and complete `/_emdash/admin/setup`.

4. Test setup with sample content included and excluded.

5. Open every route with seeded content, newly created content, no optional image, and an empty collection.

6. Edit site settings, menus, taxonomy assignments, Portable Text, and blocks through the admin. For blocks, add, reorder, duplicate, and remove each allowed type. Confirm the public routes use the changed values.

7. Apply the deployment-specific setup in a disposable environment and verify media storage, previews, and server rendering.

> **Caution**
>
> Rebuilding a template does not reapply its seed to an initialized database. Test seed changes against a fresh database, and use schema evolution procedures for an existing deployed site.

## Publish the template

A GitHub repository can be used directly with Astro's `github:` template syntax:

```bash
npm create astro@latest -- --template github:example/emdash-theme-publication
```

Before publishing, remove local databases and uploads, keep secrets out of the repository, verify package paths from a clean checkout, and document which deployment target the template configures.

## Checklist

- [ ] `astro.config.mjs` uses server output and the intended deployment adapters.
- [ ] `src/live.config.ts` registers `emdashLoader()`.
- [ ] `package.json#emdash.seed` points to an existing file.
- [ ] Every queried collection and field is declared by the seed.
- [ ] Query examples use `orderBy` and the correct entry identifier.
- [ ] CMS-managed site values are not duplicated as permanent template constants.
- [ ] A clean setup works with and without sample content.
- [ ] The template build and typecheck pass for its supported target.
