Skip to content

EmDash for Astro Developers

EmDash is a CMS built specifically for Astro. 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 adds content management on top of your existing Astro 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, Cloudflare D1, or PostgreSQL
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/SQL database (SQLite, libSQL, D1, or Postgres)
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.

The following configuration registers EmDash as an Astro integration in server output mode:

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",
}),
}),
],
});

The following file registers EmDash as a live content source:

src/live.config.ts
import { defineLiveCollection } from "astro:content";
import { emdashLoader } from "emdash/runtime";
export const collections = {
_emdash: defineLiveCollection({
loader: emdashLoader(),
}),
};

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.

The following layout fetches a menu by location and renders it with nested items:

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>
)}

The following layout fetches a widget area and renders each widget:

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>
)}

The following component reads global site settings and renders a logo or title:

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: ["content:read"],
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, so content is served at runtime and changes appear immediately.

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 });
}
---