EmDash for Astro Developers

On this page

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

FeatureWhat it provides
AdminBrowser-based collection, media, menu, taxonomy, and settings management at /_emdash/admin
Database collectionsEditor-managed content queried at request time
Media libraryStored images and files with media field values for templates
Drafts, revisions, and previewsEditorial work before publication
Menus and widget areasOrdered, editable site regions outside entry fields
Site settingsShared identity and display values such as title, tagline, logo, and pagination size
PluginsHooks, 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 collectionEmDash collection
StorageFiles in the projectSQL database
EditingRepository workflowEmDash admin
QuerygetCollection()getEmDashCollection()
Rich textMarkdown or MDXPortable Text
DeliveryBuild-time or live loaderRuntime 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.

Next steps