EmDash adds an admin application, database-backed collections, media, menus, taxonomies, settings, revisions, and previews to an Astro site. Pages and components remain ordinary Astro files.
What EmDash adds
| Feature | What it provides |
|---|---|
| Admin | Browser-based collection, media, menu, taxonomy, and settings management at /_emdash/admin |
| Database collections | Editor-managed content queried at request time |
| Media library | Stored images and files with media field values for templates |
| Drafts, revisions, and previews | Editorial work before publication |
| Menus and widget areas | Ordered, editable site regions outside entry fields |
| Site settings | Shared identity and display values such as title, tagline, logo, and pagination size |
| Plugins | Hooks, routes, storage, and optional admin extensions |
These features live alongside Astro rather than replacing it. Astro still controls routing, layouts, rendering, styles, and the deployment adapter.
EmDash and Astro collections
Astro content collections and EmDash collections can coexist. Use Astro collections for repository-owned content and EmDash for content managed through /_emdash/admin.
| Astro content collection | EmDash collection | |
|---|---|---|
| Storage | Files in the project | SQL database |
| Editing | Repository workflow | EmDash admin |
| Query | getCollection() | getEmDashCollection() |
| Rich text | Markdown or MDX | Portable Text |
| Delivery | Build-time or live loader | Runtime live loader |
Use both collection systems when ownership differs. For example, a product site can keep developer-authored release notes in an Astro content collection and editor-authored articles in EmDash:
---
import { getCollection } from "astro:content";
import { getEmDashCollection } from "emdash";
const [releaseNotes, { entries: articles }] = await Promise.all([
getCollection("releases"),
getEmDashCollection("articles", { limit: 3 }),
]);
---
The two results remain separate. EmDash does not copy file-based entries into its database.
Configure a site
The current Node templates configure Astro for server output, add the EmDash integration, and use the SQLite and local-storage adapters.
The following reduced configuration contains those required pieces:
import node from "@astrojs/node";
import react from "@astrojs/react";
import { defineConfig } from "astro/config";
import emdash, { local } from "emdash/astro";
import { sqlite } from "emdash/db";
export default defineConfig({
output: "server",
adapter: node({ mode: "standalone" }),
integrations: [
react(),
emdash({
database: sqlite({ url: "file:./data.db" }),
storage: local({
directory: "./uploads",
baseUrl: "/_emdash/api/media/file",
}),
}),
],
});
EmDash also supplies Cloudflare templates configured for D1 and R2. Start from the template for the deployment target instead of translating the Node adapters by hand.
Register the live collection
The templates expose EmDash content through one Astro live collection named _emdash:
import { defineLiveCollection } from "astro:content";
import { emdashLoader } from "emdash/runtime";
export const collections = {
_emdash: defineLiveCollection({ loader: emdashLoader() }),
};
getEmDashCollection() and getEmDashEntry() select the requested content type through this loader.
Query collections
The following query reads the most recently published posts. orderBy uses stored field names and maps each name to "asc" or "desc":
---
import { getEmDashCollection } from "emdash";
const { entries: posts, error, cacheHint } = await getEmDashCollection("posts", {
orderBy: { published_at: "desc" },
limit: 10,
});
if (error) return new Response("Could not load posts", { status: 500 });
if (Astro.cache?.enabled) Astro.cache.set(cacheHint);
---
{posts.map((post) => (
<article>
<h2><a href={`/posts/${post.id}`}>{post.data.title}</a></h2>
</article>
))}
Anonymous queries return published content. An explicit status filter is useful in authenticated or preview-aware code. where accepts content fields and taxonomy names; see Querying content for the complete filter and pagination shapes.
Query one entry
Pass a slug or database ID to getEmDashEntry(). The following route uses its URL slug:
---
import { decodeSlug, getEmDashEntry } from "emdash";
import { PortableText } from "emdash/ui";
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);
---
<article>
<h1>{post.data.title}</h1>
<PortableText value={post.data.content} />
</article>
The returned entry.id is Astro’s route identifier and is normally the slug. The database content ID is entry.data.id. Use data.id with helpers that require a stored content ID.
Use dynamic CMS features
EmDash exports server helpers for data that does not belong to a single collection entry:
---
import { getMenu, getSiteSettings } from "emdash";
import { WidgetArea } from "emdash/ui";
const [menu, settings] = await Promise.all([
getMenu("primary"),
getSiteSettings(),
]);
---
<header>
<a href="/">{settings.title}</a>
<nav>
{menu?.items.map((item) => <a href={item.url}>{item.label}</a>)}
</nav>
</header>
<main><slot /></main>
<aside><WidgetArea name="sidebar" /></aside>
Choose a plugin format
Sandboxed and native plugins have different package shapes. Sandboxed plugins use emdash-plugin.jsonc plus a default-exported src/plugin.ts object. Native plugins export a descriptor factory and createPlugin() built with definePlugin().
Read Choosing a plugin format before adding a plugin. Do not copy a native definePlugin() example into a sandboxed package.