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:
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:
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:
{
"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:
{
"$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 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:
---
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:
---
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.redirect("/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.redirect("/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:
---
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:
---
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:
{
"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:
---
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.redirect("/404");
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} />
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:
---
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:
{
"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 custom Portable Text blocks
If a theme needs a custom public renderer, add the block shape to seeded Portable Text and map its _type to an Astro component in the template. A seed does not register an editor UI for a new block type.
The marketing template uses namespaced values such as marketing.hero. Its wrapper maps those values to Astro components:
---
import type { PortableTextBlock } from "emdash";
import { PortableText } from "emdash/ui";
import Hero from "./blocks/Hero.astro";
import Features from "./blocks/Features.astro";
interface Props {
value: PortableTextBlock[];
}
const { value } = Astro.props;
const marketingTypes = {
"marketing.hero": Hero,
"marketing.features": Features,
};
---
<PortableText value={value} components={{ type: marketingTypes }} />
Keep the data shape in the seed and the component props in sync. Give every Portable Text object a stable _key so the editor can address it reliably.
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
-
Scaffold the template into a clean directory with
create-astro. -
Install dependencies and run the site’s build and typecheck commands.
-
Start with an empty database and complete
/_emdash/admin/setup. -
Test setup with sample content included and excluded.
-
Open every route with seeded content, newly created content, no optional image, and an empty collection.
-
Edit site settings, menus, taxonomy assignments, and Portable Text through the admin. Confirm the public routes use the changed values.
-
Apply the deployment-specific setup in a disposable environment and verify media storage, previews, and server rendering.
Publish the template
A GitHub repository can be used directly with Astro’s github: template syntax:
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.mjsuses server output and the intended deployment adapters. -
src/live.config.tsregistersemdashLoader(). -
package.json#emdash.seedpoints to an existing file. - Every queried collection and field is declared by the seed.
- Query examples use
orderByand 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.