Getting Started
Create your first EmDash site in under 5 minutes.
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.
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’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 |
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 filesconst docs = await getCollection("docs");
// Editor-managed posts from databaseconst { entries: posts } = await getEmDashCollection("posts", { status: "published", limit: 5,});---EmDash requires two configuration files.
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", }), }), ],});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).
EmDash provides query functions that follow Astro’s live content collections pattern, returning { entries, error } or { entry, error }:
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");import { getCollection, getEntry } from "astro:content";
// Get all blog entriesconst posts = await getCollection("blog");
// Get a single entry by slugconst post = await getEntry("blog", "my-post");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});EmDash stores rich text as Portable Text, a structured JSON format. Render it with the PortableText component:
---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>---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>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>)}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>)}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>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" }, }, },});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 });}---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.