Skip to content

EmDash for Astro Developers

EmDash is a CMS built specifically for Astro—not a generic headless CMS with an Astro adapter. It extends your Astro site with database-backed content, a polished admin UI, and WordPress-style features (menus, widgets, taxonomies) while preserving the developer experience you expect.

Everything you know about Astro still applies. EmDash enhances your site; it doesn’t replace your workflow.

EmDash provides the content management features that file-based Astro sites lack:

FeatureDescription
Admin UIFull WYSIWYG editing interface at /_emdash/admin
Database storageContent stored in SQLite, libSQL, or Cloudflare D1
Media libraryUpload, organize, and serve images and files
Navigation menusDrag-and-drop menu management with nesting
Widget areasDynamic sidebars and footer regions
Site settingsGlobal configuration (title, logo, social links)
TaxonomiesCategories, tags, and custom taxonomies
Preview systemSigned preview URLs for draft content
RevisionsContent version history

Astro’s astro:content collections are file-based and resolved at build time. EmDash collections are database-backed and resolved at runtime.

Astro CollectionsEmDash Collections
StorageMarkdown/MDX files in src/content/SQLite/D1 database
EditingCode editorAdmin UI
Content formatMarkdown with frontmatterPortable Text (structured JSON)
UpdatesRequires rebuildInstant (SSR)
SchemaZod in content.config.tsDefined in admin, stored in database
Best forDeveloper-managed contentEditor-managed content

Astro collections and EmDash can coexist. Use Astro collections for developer content (docs, changelogs) and EmDash for editor content (blog posts, pages):

src/pages/index.astro
---
import { getCollection } from "astro:content";
import { getEmDashCollection } from "emdash";
// Developer-managed docs from files
const docs = await getCollection("docs");
// Editor-managed posts from database
const { entries: posts } = await getEmDashCollection("posts", {
status: "published",
limit: 5,
});
---

EmDash requires two configuration files.

astro.config.mjs
import { defineConfig } from "astro/config";
import emdash, { local } from "emdash/astro";
import { sqlite } from "emdash/db";
export default defineConfig({
output: "server", // Required for EmDash
integrations: [
emdash({
database: sqlite({ url: "file:./data.db" }),
storage: local({
directory: "./uploads",
baseUrl: "/_emdash/api/media/file",
}),
}),
],
});
src/live.config.ts
import { defineLiveCollection } from "astro:content";
import { emdashLoader } from "emdash/runtime";
export const collections = {
_emdash: defineLiveCollection({
loader: emdashLoader(),
}),
};

This registers EmDash as a live content source. The _emdash collection internally routes to your content types (posts, pages, products).

EmDash provides query functions that follow Astro’s live content collections pattern, returning { entries, error } or { entry, error }:

import { getEmDashCollection, getEmDashEntry } from "emdash";
// Get all published posts - returns { entries, error }
const { entries: posts } = await getEmDashCollection("posts", {
status: "published",
});
// Get a single post by slug - returns { entry, error, isPreview }
const { entry: post } = await getEmDashEntry("posts", "my-post");

getEmDashCollection supports filtering that Astro’s getCollection doesn’t:

const { entries: posts } = await getEmDashCollection("posts", {
status: "published", // draft | published | archived
limit: 10, // max results
where: { category: "news" }, // taxonomy filter
});

EmDash stores rich text as Portable Text, a structured JSON format. Render it with the PortableText component:

src/pages/posts/[slug].astro
---
import { getEmDashEntry } from "emdash";
import { PortableText } from "emdash/ui";
const { slug } = Astro.params;
const { entry: post } = await getEmDashEntry("posts", slug);
if (!post) {
return Astro.redirect("/404");
}
---
<article>
<h1>{post.data.title}</h1>
<PortableText value={post.data.content} />
</article>

EmDash provides APIs for WordPress-style features that don’t exist in Astro’s content layer.

src/layouts/Base.astro
---
import { getMenu } from "emdash";
const primaryMenu = await getMenu("primary");
---
{primaryMenu && (
<nav>
<ul>
{primaryMenu.items.map(item => (
<li>
<a href={item.url}>{item.label}</a>
{item.children.length > 0 && (
<ul>
{item.children.map(child => (
<li><a href={child.url}>{child.label}</a></li>
))}
</ul>
)}
</li>
))}
</ul>
</nav>
)}
src/layouts/BlogPost.astro
---
import { getWidgetArea } from "emdash";
import { PortableText } from "emdash/ui";
const sidebar = await getWidgetArea("sidebar");
---
{sidebar && sidebar.widgets.length > 0 && (
<aside>
{sidebar.widgets.map(widget => (
<div class="widget">
{widget.title && <h3>{widget.title}</h3>}
{widget.type === "content" && widget.content && (
<PortableText value={widget.content} />
)}
</div>
))}
</aside>
)}
src/components/Header.astro
---
import { getSiteSettings, getSiteSetting } from "emdash";
const settings = await getSiteSettings();
// Or fetch individual values:
const title = await getSiteSetting("title");
---
<header>
{settings.logo ? (
<img src={settings.logo.url} alt={settings.title} />
) : (
<span>{settings.title}</span>
)}
{settings.tagline && <p>{settings.tagline}</p>}
</header>

Extend EmDash with plugins that add hooks, storage, settings, and admin UI:

astro.config.mjs
import emdash from "emdash/astro";
import seoPlugin from "@emdash-cms/plugin-seo";
export default defineConfig({
integrations: [
emdash({
// ...
plugins: [seoPlugin({ generateSitemap: true })],
}),
],
});

Create custom plugins with definePlugin:

src/plugins/analytics.ts
import { definePlugin } from "emdash";
export default definePlugin({
id: "analytics",
version: "1.0.0",
capabilities: ["read:content"],
hooks: {
"content:afterSave": async (event, ctx) => {
ctx.log.info("Content saved", { id: event.content.id });
},
},
admin: {
settingsSchema: {
trackingId: { type: "string", label: "Tracking ID" },
},
},
});

EmDash sites run in SSR mode. Content changes appear immediately without rebuilds.

For static pages with getStaticPaths, content is fetched at build time:

src/pages/posts/[slug].astro
---
import { getEmDashCollection, getEmDashEntry } from "emdash";
export async function getStaticPaths() {
const { entries: posts } = await getEmDashCollection("posts", {
status: "published",
});
return posts.map((post) => ({
params: { slug: post.data.slug },
}));
}
const { slug } = Astro.params;
const { entry: post } = await getEmDashEntry("posts", slug);
---

For dynamic pages, set prerender = false to fetch content on each request:

src/pages/posts/[slug].astro
---
export const prerender = false;
import { getEmDashEntry } from "emdash";
const { slug } = Astro.params;
const { entry: post, error } = await getEmDashEntry("posts", slug);
if (error) {
return new Response("Server error", { status: 500 });
}
if (!post) {
return new Response(null, { status: 404 });
}
---