Skip to content

Architecture

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

┌──────────────────────────────────────────────────────────────────┐
│ 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.

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

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, archived
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

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");

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

Path PatternPurpose
/_emdash/admin/[...path]Admin panel SPA
/_emdash/api/manifestAdmin manifest (collections, plugins)
/_emdash/api/content/[collection]CRUD for content entries
/_emdash/api/media/*Media library operations
/_emdash/api/schema/*Schema management
/_emdash/api/settingsSite 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.

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",
}),
}),
],
});

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.

Plugins extend EmDash through a WordPress-inspired hook system:

  • Content hooksbeforeCreate, afterCreate, beforeUpdate, afterUpdate, beforeDelete
  • Media hooksbeforeMediaUpload, afterMediaUpload
  • 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)
astro.config.mjs
import { seoPlugin } from "@emdash-cms/plugin-seo";
emdash({
plugins: [seoPlugin({ maxTitleLength: 60 })],
});

A typical content request follows this path:

  1. Astro receives request — Your page component runs 2. Query contentgetEmDashCollection() calls Astro’s getLiveCollection() 3. Loader executesemdashLoader 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

For admin requests:

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

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

ModulePurpose
virtual:emdash/configDatabase and storage configuration
virtual:emdash/dialectDatabase dialect factory
virtual:emdash/plugin-adminsStatic imports for plugin admin UIs

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