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.
What EmDash Adds
EmDash provides the content management features that file-based Astro sites lack:
| Feature | Description |
|---|---|
| Admin UI | Full WYSIWYG editing interface at /_emdash/admin |
| Database storage | Content stored in SQLite, libSQL, Cloudflare D1, or PostgreSQL |
| Media library | Upload, organize, and serve images and files |
| Navigation menus | Drag-and-drop menu management with nesting |
| Widget areas | Dynamic sidebars and footer regions |
| Site settings | Global configuration (title, logo, social links) |
| Taxonomies | Categories, tags, and custom taxonomies |
| Preview system | Signed preview URLs for draft content |
| Revisions | Content version history |
Astro Collections vs EmDash
Astro’s astro:content collections are file-based and resolved at build time. EmDash collections are database-backed and resolved at runtime.
| Astro Collections | EmDash Collections | |
|---|---|---|
| Storage | Markdown/MDX files in src/content/ | SQL database (SQLite, libSQL, D1, or Postgres) |
| Editing | Code editor | Admin UI |
| Content format | Markdown with frontmatter | Portable Text (structured JSON) |
| Updates | Requires rebuild | Instant (SSR) |
| Schema | Zod in content.config.ts | Defined in admin, stored in database |
| Best for | Developer-managed content | Editor-managed content |
Use Both Together
Astro collections and EmDash can coexist. Use Astro collections for developer content (docs, changelogs) and EmDash for editor content (blog posts, pages):
---
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,
});
---
Configuration
EmDash requires two configuration files.
Astro Integration
The following configuration registers EmDash as an Astro integration in server output mode:
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",
}),
}),
],
});
Live Collections Loader
The following file registers EmDash as a live content source:
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).
Querying Content
EmDash provides query functions that follow Astro’s live content collections pattern, returning { entries, error } or { entry, error }:
EmDash
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");
Astro
import { getCollection, getEntry } from "astro:content";
// Get all blog entries
const posts = await getCollection("blog");
// Get a single entry by slug
const post = await getEntry("blog", "my-post"); Filtering Options
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
});
Rendering Content
EmDash stores rich text as Portable Text, a structured JSON format. Render it with the PortableText component:
EmDash
---
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> Astro
---
import { getEntry, render } from "astro:content";
const { slug } = Astro.params;
const post = await getEntry("blog", slug);
const { Content } = await render(post);
---
<article>
<h1>{post.data.title}</h1>
<Content />
</article> Dynamic Features
EmDash provides APIs for WordPress-style features that don’t exist in Astro’s content layer.
Navigation Menus
The following layout fetches a menu by location and renders it with nested items:
---
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>
)}
Widget Areas
The following layout fetches a widget area and renders each widget:
---
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>
)}
Site Settings
The following component reads global site settings and renders a logo or title:
---
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>
Plugins
Extend EmDash with plugins that add hooks, storage, settings, and admin UI:
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:
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" },
},
},
});
Server Rendering
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:
---
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:
---
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 });
}
---
Next Steps
Getting Started
Create your first EmDash site in under 5 minutes.
Querying Content
Learn the query API in detail.
Create a Blog
Build a complete blog with categories and tags.
Deploy to Cloudflare
Take your site to production on Workers.