Skip to content

EmDash for Astro Developers

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.

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.

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:

src/pages/index.astro
---
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.

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:

astro.config.mjs
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.

The templates expose EmDash content through one Astro live collection named _emdash:

src/live.config.ts
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.

The following query reads the most recently published posts. orderBy uses stored field names and maps each name to "asc" or "desc":

src/pages/posts/index.astro
---
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.

Pass a slug or database ID to getEmDashEntry(). The following route uses its URL slug:

src/pages/posts/[slug].astro
---
import { decodeSlug, getEmDashEntry } from "emdash";
import { PortableText } from "emdash/ui";
const slug = decodeSlug(Astro.params.slug);
if (!slug) return Astro.rewrite("/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.rewrite("/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.

EmDash exports server helpers for data that does not belong to a single collection entry:

src/layouts/Base.astro
---
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>

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.

Query content

Use filters, pagination, locales, and cache hints.

Create a theme

Package an Astro site and its seed file as a reusable template.