EmDash for Astro Developers

On this page

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:

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

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.

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