SQLite
Local development with sqlite({ url: "file:./data.db" })
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 collectionWhen you create a “products” collection with title and price fields via the admin UI, EmDash:
_emdash_collections and _emdash_fieldsALTER TABLE to create ec_products with the appropriate columnsThis design enables:
Each collection gets its own SQLite table with an ec_ prefix:
-- Created when "posts" collection is addedCREATE 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?
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:
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 postsconst { entries: posts } = await getEmDashCollection("posts");
// Get draftsconst { entries: drafts } = await getEmDashCollection("posts", { status: "draft",});
// Get a single entry by slugconst { entry: post } = await getEmDashEntry("posts", "my-post-slug");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.
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:
Uploads use signed URLs for direct client-to-storage uploads, bypassing Workers body size limits.
Plugins extend EmDash through a WordPress-inspired hook system:
beforeCreate, afterCreate, beforeUpdate, afterUpdate, beforeDeletebeforeMediaUpload, afterMediaUploadPlugins can run in two modes:
import { seoPlugin } from "@emdash-cms/plugin-seo";
emdash({ plugins: [seoPlugin({ maxTitleLength: 60 })],});A typical content request follows this path:
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 HTMLFor admin requests:
beforeCreate, afterUpdate, etc. 4. Database
updates — Kysely executes SQL 5. Response returned — JSON response to admin SPAEmDash 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.
Collections
Learn about content collections and field types.
Content Model
Understand the database-first content model.
Admin Panel
Explore the admin panel architecture.