---
title: EmDash for Astro Developers
description: Add WordPress-style CMS features to your Astro site with EmDash
---

import { Aside, Card, CardGrid, Tabs, TabItem } from "@astrojs/starlight/components";

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.

## What EmDash Adds

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                              |

<Aside type="tip">
	Content changes appear immediately without rebuilding your site. EmDash sites run in SSR mode by
	default.
</Aside>

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

### 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):

```astro title="src/pages/index.astro"
---
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

```ts title="astro.config.mjs"
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

```ts title="src/live.config.ts"
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).

## Querying Content

EmDash provides query functions that follow Astro's [live content collections](https://docs.astro.build/en/reference/experimental-flags/live-content-collections/) pattern, returning `{ entries, error }` or `{ entry, error }`:

<Tabs>
  <TabItem label="EmDash">
```ts
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");

````
  </TabItem>
  <TabItem label="Astro">
```ts
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");
````

  </TabItem>
</Tabs>

### Filtering Options

`getEmDashCollection` supports filtering that Astro's `getCollection` doesn't:

```ts
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:

<Tabs>
  <TabItem label="EmDash">
```astro title="src/pages/posts/[slug].astro"
---
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>
```
  </TabItem>
  <TabItem label="Astro">
```astro title="src/pages/blog/[slug].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>
```
  </TabItem>
</Tabs>

<Aside>
	Portable Text preserves content structure without embedding HTML. This makes content portable
	across renderers and prevents XSS vulnerabilities.
</Aside>

## Dynamic Features

EmDash provides APIs for WordPress-style features that don't exist in Astro's content layer.

### Navigation Menus

```astro title="src/layouts/Base.astro"
---
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

```astro title="src/layouts/BlogPost.astro"
---
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

```astro title="src/components/Header.astro"
---
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:

```ts title="astro.config.mjs"
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`:

```ts title="src/plugins/analytics.ts"
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. Content changes appear immediately without rebuilds.

For static pages with `getStaticPaths`, content is fetched at build time:

```astro title="src/pages/posts/[slug].astro"
---
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:

```astro title="src/pages/posts/[slug].astro"
---
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 });
}
---
```

<Aside type="tip">
	Use server rendering for frequently updated content. Use static generation for content that
	changes rarely and benefits from CDN caching.
</Aside>

## Next Steps

<CardGrid>
	<Card title="Getting Started" icon="rocket">
		[Create your first EmDash site](/getting-started/) in under 5 minutes.
	</Card>
	<Card title="Querying Content" icon="document">
		[Learn the query API](/guides/querying-content/) in detail.
	</Card>
	<Card title="Create a Blog" icon="pencil">
		[Build a complete blog](/guides/create-a-blog/) with categories and tags.
	</Card>
	<Card title="Deploy to Cloudflare" icon="external">
		[Take your site to production](/deployment/cloudflare/) on Workers.
	</Card>
</CardGrid>
