---
title: Architecture
description: How EmDash works under the hood—database-first schema, Live Collections, plugin system, and admin panel.
---

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

EmDash integrates deeply with Astro to provide a complete CMS experience. This page explains the key architectural decisions and how the pieces fit together.

## High-Level Overview

```
┌──────────────────────────────────────────────────────────────────┐
│                         Your Astro Site                          │
│                                                                  │
│  ┌────────────────────────────────────────────────────────────┐  │
│  │                   EmDash Integration                     │  │
│  │                                                            │  │
│  │  ┌──────────────┐   ┌──────────────┐   ┌───────────────┐   │  │
│  │  │   Content    │   │    Admin     │   │    Plugins    │   │  │
│  │  │    APIs      │   │    Panel     │   │               │   │  │
│  │  └──────────────┘   └──────────────┘   └───────────────┘   │  │
│  │                                                            │  │
│  │  ┌──────────────────────────────────────────────────────┐  │  │
│  │  │                    Data Layer                        │  │  │
│  │  │      Database (D1/SQLite)     +  Storage (R2/S3)     │  │  │
│  │  └──────────────────────────────────────────────────────┘  │  │
│  └────────────────────────────────────────────────────────────┘  │
│                                                                  │
│  ┌────────────────────────────────────────────────────────────┐  │
│  │                    Astro Framework                         │  │
│  │         Live Collections · Middleware · Sessions           │  │
│  └────────────────────────────────────────────────────────────┘  │
└──────────────────────────────────────────────────────────────────┘
```

EmDash runs as an Astro integration. It injects routes for the admin panel and REST APIs, provides a content loader for Live Collections, and manages database migrations and storage connections.

## Database-First Schema

Unlike traditional CMSs that define schema in code, EmDash stores schema definitions in the database itself. Two system tables track your content structure:

- `_emdash_collections` — Collection metadata (slug, label, features)
- `_emdash_fields` — Field definitions for each collection

When you create a "products" collection with title and price fields via the admin UI, EmDash:

1. Inserts records into `_emdash_collections` and `_emdash_fields`
2. Runs `ALTER TABLE` to create `ec_products` with the appropriate columns

This design enables:

- **Runtime schema modification** — Create and edit content types without code changes or rebuilds
- **Non-developer-friendly setup** — Content editors can design their data model through the UI
- **Real SQL columns** — Proper indexing, foreign keys, and query optimization

<Aside type="tip">
	Run `npx emdash types` to generate TypeScript types from the live schema. This gives you type
	safety for dynamically-defined collections.
</Aside>

## Per-Collection Tables

Each collection gets its own SQLite table with an `ec_` prefix:

```sql
-- Created when "posts" collection is added
CREATE TABLE ec_posts (
  -- System columns (always present)
  id TEXT PRIMARY KEY,
  slug TEXT UNIQUE,
  status TEXT DEFAULT 'draft',  -- draft, published, scheduled
  author_id TEXT,
  created_at TEXT DEFAULT (datetime('now')),
  updated_at TEXT DEFAULT (datetime('now')),
  published_at TEXT,
  deleted_at TEXT,              -- Soft delete
  version INTEGER DEFAULT 1,    -- Optimistic locking

  -- Content columns (from your field definitions)
  title TEXT NOT NULL,
  content JSON,                 -- Portable Text
  excerpt TEXT
);
```

**Why per-collection tables instead of a single content table with JSON?**

- Real SQL columns enable proper indexing and queries
- Foreign keys work correctly
- Schema is self-documenting in the database
- No JSON parsing overhead for field access
- Database tools can inspect schema directly

## Live Collections Integration

EmDash uses Astro 6's Live Collections to serve content at runtime. Content changes are immediately available without static rebuilds.

The `emdashLoader()` implements Astro's `LiveLoader` interface:

```ts
// src/live.config.ts
import { defineLiveCollection } from "astro:content";
import { emdashLoader } from "emdash/runtime";

export const collections = {
	_emdash: defineLiveCollection({ loader: emdashLoader() }),
};
```

<Aside type="note">
	A single `_emdash` Astro collection wraps all your content types. The loader filters by type
	internally when you call `getEmDashCollection("posts")`.
</Aside>

Query content using the provided wrapper functions:

```ts
import { getEmDashCollection, getEmDashEntry } from "emdash";

// Get all published posts
const { entries: posts } = await getEmDashCollection("posts");

// Get drafts
const { entries: drafts } = await getEmDashCollection("posts", {
	status: "draft",
});

// Get a single entry by slug
const { entry: post } = await getEmDashEntry("posts", "my-post-slug");
```

## Route Injection

The EmDash integration uses Astro's `injectRoute` API to add admin and API routes:

| Path Pattern                          | Purpose                               |
| ------------------------------------- | ------------------------------------- |
| `/_emdash/admin/[...path]`          | Admin panel SPA                       |
| `/_emdash/api/manifest`             | Admin manifest (collections, plugins) |
| `/_emdash/api/content/[collection]` | CRUD for content entries              |
| `/_emdash/api/media/*`              | Media library operations              |
| `/_emdash/api/schema/*`             | Schema management                     |
| `/_emdash/api/settings`             | Site settings                         |
| `/_emdash/api/menus/*`              | Navigation menus                      |
| `/_emdash/api/taxonomies/*`         | Categories, tags, custom taxonomies   |

Routes are injected from the `emdash` package—nothing is copied into your project.

## Data Layer

EmDash uses [Kysely](https://kysely.dev) for type-safe SQL queries across all supported databases:

<CardGrid>
  <Card title="SQLite" icon="laptop">
    Local development with `sqlite({ url: "file:./data.db" })`
  </Card>
  <Card title="D1" icon="cloudflare">
    Cloudflare's serverless SQL with `d1({ binding: "DB" })`
  </Card>
  <Card title="libSQL" icon="external">
    Remote SQLite with `libsql({ url: "...", authToken: "..." })`
  </Card>
</CardGrid>

Database configuration is passed to the integration in `astro.config.mjs`:

```ts
import { defineConfig } from "astro/config";
import emdash from "emdash/astro";
import { sqlite } from "emdash/db";
import { local } from "emdash/storage";

export default defineConfig({
	integrations: [
		emdash({
			database: sqlite({ url: "file:./data.db" }),
			storage: local({
				directory: "./uploads",
				baseUrl: "/_emdash/api/media/file",
			}),
		}),
	],
});
```

## Storage Abstraction

Media files are stored separately from the database. EmDash supports:

- **Local filesystem** — Development and simple deployments
- **Cloudflare R2** — S3-compatible object storage on the edge
- **S3-compatible** — Any S3-compatible object storage

Uploads use signed URLs for direct client-to-storage uploads, bypassing Workers body size limits.

## Plugin Architecture

Plugins extend EmDash through a WordPress-inspired hook system:

- **Content hooks** — `content:beforeSave`, `content:afterSave`, `content:beforeDelete`, `content:afterDelete`
- **Media hooks** — `media:beforeUpload`, `media:afterUpload`
- **Isolated storage** — Each plugin gets namespaced KV access
- **Admin UI extensions** — Dashboard widgets, settings pages, custom field editors

Plugins can run in two modes:

1. **Trusted** — Full access to the host environment (for first-party plugins)
2. **Sandboxed** — Run in V8 isolates with capability-based permissions (for third-party plugins on Cloudflare)

```ts
// astro.config.mjs
import { seoPlugin } from "@emdash-cms/plugin-seo";

emdash({
	plugins: [seoPlugin({ maxTitleLength: 60 })],
});
```

## Request Flow

A typical content request follows this path:

<Steps>
	1. **Astro receives request** — Your page component runs 2. **Query content** —
	`getEmDashCollection()` calls Astro's `getLiveCollection()` 3. **Loader executes** —
	`emdashLoader` queries the appropriate `ec_*` table via Kysely 4. **Data returned** — Entries
	are mapped to Astro's entry format with `id`, `slug`, and `data` 5. **Page renders** — Your
	component receives the content and renders HTML
</Steps>

For admin requests:

<Steps>
	1. **Middleware authenticates** — Validates session token 2. **API route handles request** — CRUD
	operations via repositories 3. **Hooks fire** — `beforeCreate`, `afterUpdate`, etc. 4. **Database
	updates** — Kysely executes SQL 5. **Response returned** — JSON response to admin SPA
</Steps>

## Virtual Modules

EmDash generates virtual modules at build time to configure the runtime:

| Module                           | Purpose                             |
| -------------------------------- | ----------------------------------- |
| `virtual:emdash/config`        | Database and storage configuration  |
| `virtual:emdash/dialect`       | Database dialect factory            |
| `virtual:emdash/plugin-admins` | Static imports for plugin admin UIs |

This approach ensures bundlers can properly resolve and tree-shake plugin code.

## Next Steps

<CardGrid>
	<Card title="Collections" icon="document">
		Learn about [content collections and field types](/concepts/collections/).
	</Card>
	<Card title="Content Model" icon="open-book">
		Understand the [database-first content model](/concepts/content-model/).
	</Card>
	<Card title="Admin Panel" icon="setting">
		Explore the [admin panel architecture](/concepts/admin-panel/).
	</Card>
</CardGrid>
