Getting Started
Create your first EmDash site in under 5 minutes.
EmDash is a CMS built specifically for Astro—not a generic headless CMS with an Astro adapter. 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 enhances your site; it doesn’t replace your 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, or Cloudflare D1 |
| 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/ | SQLite/D1 database |
| 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.
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", }), }), ],});import { defineLiveCollection } from "astro:content";import { emdashLoader } from "emdash/runtime";
export const collections = { _emdash: defineLiveCollection({ loader: emdashLoader(), }),};This registers EmDash as a live content source. 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.
---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>)}---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>)}---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: ["read:content"],
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. Content changes appear immediately without rebuilds.
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.