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:
- Inserts records into
_emdash_collectionsand_emdash_fields - Runs
ALTER TABLEto createec_productswith 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
Per-Collection Tables
Each collection gets its own SQLite table with an ec_ prefix:
-- 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:
// src/live.config.ts
import { defineLiveCollection } from "astro:content";
import { emdashLoader } from "emdash/runtime";
export const collections = {
_emdash: defineLiveCollection({ loader: emdashLoader() }),
};
Query content using the provided wrapper functions:
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 for type-safe SQL queries across all supported databases:
SQLite
Local development with sqlite({ url: "file:./data.db" })
D1
Cloudflare’s serverless SQL with d1({ binding: "DB" })
libSQL
Remote SQLite with libsql({ url: "...", authToken: "..." })
Database configuration is passed to the integration in astro.config.mjs:
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:
- Native — Full access to the host environment (for first-party plugins)
- Sandboxed — Run in V8 isolates with capability-based permissions (for third-party plugins on Cloudflare)
// astro.config.mjs
import { seoPlugin } from "@emdash-cms/plugin-seo";
emdash({
plugins: [seoPlugin({ maxTitleLength: 60 })],
});
Request Flow
A typical content request follows this path:
- Astro receives request — Your page component runs 2. Query content —
getEmDashCollection()calls Astro’sgetLiveCollection()3. Loader executes —emdashLoaderqueries the appropriateec_*table via Kysely 4. Data returned — Entries are mapped to Astro’s entry format withid,slug, anddata5. Page renders — Your component receives the content and renders HTML
For admin requests:
- 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
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
Collections
Learn about content collections and field types.
Content Model
Understand the database-first content model.
Admin Panel
Explore the admin panel architecture.