This page is for people working on EmDash, not building a site with it. It explains the database layout, Astro integration, request paths, admin application, media flow, and import system. If you are building a site, read Architecture and the Content Model instead.
The Astro integration
EmDash runs as an Astro integration from the emdash package. At build time it:
-
Injects the admin application and REST API routes with Astro’s
injectRouteAPI. Nothing is copied into the user’s project. The main route families are:Path pattern Purpose /_emdash/admin/[...path]Admin panel SPA /_emdash/api/manifestAdmin manifest (collections, plugins) /_emdash/api/content/[collection]/...Content entry operations /_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 /_emdash/api/plugins/[pluginId]/[...path]Plugin-defined API routes The route injector is the complete inventory, including authentication, comments, search, imports, widgets, and other route families.
-
Generates virtual modules so the bundler can resolve configuration and extension code:
Module Purpose virtual:emdash/configDatabase, storage, and site configuration virtual:emdash/dialectDatabase dialect factory virtual:emdash/admin-registryStatic imports for plugin admin interfaces virtual:emdash/pluginsConfigured plugin implementations virtual:emdash/media-providersConfigured external media providers virtual-modules.tsdefines the remaining runtime helpers and generated module contents. -
Provides the Live Content Collections loader and registers the runtime middleware. At request time, the middleware opens the configured database and storage connections and applies any pending migrations before routes use them.
Database-first schema
Schema definitions live in the database, not in a static configuration file. _emdash_collections stores one row per collection. Its core columns describe the collection and the features that the runtime and admin expose:
| Columns | Purpose |
|---|---|
id, slug | Stable collection identity |
label, label_singular, description, icon | Names and guidance shown to editors |
supports, has_seo, comments_enabled, edit_locking | Optional collection capabilities |
title_field, date_field, admin_config, hidden, sort_order | Admin list and navigation behavior |
url_pattern, routable | Public URL and slug behavior |
source | How the collection was created |
The source value records provenance such as manual, seed, template:<name>, import:<name>, or discovered. Additional settings come from registered migrations, so database/types.ts and the migrations are the current column inventory.
_emdash_fields stores the fields linked to each collection:
| Columns | Purpose |
|---|---|
id, collection_id, slug | Field identity and owning collection |
label, type, column_type | Editor label, EmDash field type, and SQL storage type |
required, unique, default_value, validation | Content constraints and defaults |
widget, options, sort_order | Editor control and display order |
searchable, indexed, translatable | Search, query, and localization behavior |
collection_id references _emdash_collections.id, and each field slug is unique within its collection.
Per-collection content tables
Each collection gets its own table, prefixed ec_. A products collection with title and price fields produces a table with this shape:
CREATE TABLE ec_products (
-- System columns, present on every content table
id TEXT PRIMARY KEY,
slug TEXT,
status TEXT DEFAULT 'draft',
author_id TEXT,
primary_byline_id TEXT,
created_at TEXT DEFAULT CURRENT_TIMESTAMP,
updated_at TEXT DEFAULT CURRENT_TIMESTAMP,
published_at TEXT,
scheduled_at TEXT,
deleted_at TEXT,
version INTEGER DEFAULT 1,
live_revision_id TEXT,
draft_revision_id TEXT,
locale TEXT NOT NULL DEFAULT 'en',
translation_group TEXT,
-- Content columns, created from field definitions
title TEXT NOT NULL,
price REAL,
UNIQUE (slug, locale)
);
Real columns give each field a database type, allow indexes and foreign keys, and let database tools inspect the schema without decoding a content JSON blob. The unique constraint allows translations to share a slug while keeping each slug unique within a locale. All locale variants of the same entry share a translation_group value, which lets EmDash find the rows that are translations of one another.
The main data concerns remain separate:
| Concern | Location | Tables |
|---|---|---|
| Schema | System tables | _emdash_collections, _emdash_fields |
| Content | Per-collection tables | ec_posts, ec_products, … |
| Media | Separate table + storage | media table + configured storage |
| Settings | Options table | options with a site: prefix |
Runtime schema changes
Adding a field through the admin UI runs these steps:
- Insert the field definition into
_emdash_fields. - Add the corresponding column to the collection’s
ec_*table and create an index when the field is configured as indexed. - Refresh generated development types so the new field appears in editor tooling.
Content validation reads the current field definitions and builds a Zod schema when content is created or updated. Changing a field’s underlying SQL type, required or unique constraint, or localization behavior can require a manual content migration; SchemaRegistry rejects unsupported in-place changes instead of rebuilding the table implicitly.
Runtime validation
EmDash derives a Zod schema from the collection’s current fields. The generator delegates the type and constraint details to generateFieldSchema():
export function generateZodSchema(
collection: CollectionWithFields,
): z.ZodObject<Record<string, ZodType>> {
const shape: Record<string, ZodType> = {};
for (const field of collection.fields) {
shape[field.slug] = generateFieldSchema(field);
}
return z.object(shape);
}
The content handler also rejects unknown fields, checks required string values, and verifies references to other collections.
Data layer
EmDash uses Kysely for typed SQL across SQLite, libSQL, Cloudflare D1, and PostgreSQL. The site configuration selects the database adapter; the integration exposes its dialect factory through virtual:emdash/dialect.
Live Content Collections loader
Content is served at runtime through Astro’s Live Content Collections. emdashLoader() implements Astro’s LiveLoader interface and is registered as a single _emdash collection:
import { defineLiveCollection } from "astro:content";
import { emdashLoader } from "emdash/runtime";
export const collections = {
_emdash: defineLiveCollection({ loader: emdashLoader() }),
};
The single _emdash collection wraps every EmDash collection. getEmDashCollection("posts") supplies the posts type filter, and the loader maps it to the ec_posts table.
Request paths
A content request from an Astro page follows this path:
- The page calls
getEmDashCollection()orgetEmDashEntry(). - The query wrapper calls Astro’s
getLiveCollection()orgetLiveEntry()with the internal_emdashcollection and the requested EmDash collection type. emdashLoader()queries the relevantec_*table through Kysely, applying publication, locale, filter, ordering, and pagination rules.- The query wrapper maps rows to Astro entries and loads their bylines and taxonomy terms.
- The Astro component renders the returned entries.
Preview and edit-mode state travels through the request context, so the same query functions can return draft content after middleware verifies the request.
An admin API request follows a separate path:
- Middleware authenticates the request and stores the resolved user in
Astro.locals. - The API route parses the request and checks the permission needed for that operation.
- The route delegates business logic to a handler or repository.
- The handler runs plugin lifecycle hooks around the database operation when that operation exposes hooks.
- The route returns a standard JSON success or error response to the admin application.
Admin panel internals
The admin is a React single-page application. Astro serves its shell and the authentication middleware protects admin routes. Inside the application, TanStack Router handles navigation, TanStack Query loads server state, TanStack Table renders data grids, React Hook Form and Zod manage forms, TipTap edits Portable Text, and Kumo supplies the design system.
For session authentication, the middleware redirects an unauthenticated browser request to the login page and returns a JSON error for an unauthenticated API request. After it loads an active user, it places that user on Astro.locals for the route:
const sessionUser = await resolveSessionUser(session);
if (!sessionUser?.id) {
if (isApiRoute) {
return apiError("NOT_AUTHENTICATED", "Not authenticated", 401);
}
const loginUrl = new URL("/_emdash/admin/login", getPublicOrigin(url, emdash?.config));
loginUrl.searchParams.set("redirect", url.pathname);
return context.redirect(loginUrl.toString());
}
After this branch, the middleware loads the user, rejects missing or disabled accounts, places the active user on Astro.locals, and continues to the route.
Manifest-driven UI
The admin does not hardcode collection schemas or plugin contributions. It fetches GET /_emdash/api/manifest, which describes the current collections, fields, plugins, taxonomies, authentication mode, and other configured capabilities. An abridged manifest looks like this:
{
"collections": {
"posts": {
"label": "Blog Posts",
"labelSingular": "Post",
"supports": ["drafts", "revisions", "preview"],
"fields": {
"title": { "kind": "string", "label": "Title", "required": true }
}
}
},
"plugins": {
"audit-log": { "version": "0.2.1", "enabled": true }
},
"taxonomies": [
{ "name": "category", "label": "Categories", "hierarchical": true }
],
"version": "0.37.0"
}
The admin uses the manifest to build collection navigation and field editors. Because the endpoint reads the live schema, collection and field changes appear without rebuilding the admin application.
Plugin admin UIs
Configured plugin admin entry points are collected into virtual:emdash/admin-registry. The generated module uses static imports so the bundler can include the React components:
import * as pluginAdmin0 from "@emdash-cms/plugin-seo/admin";
export const pluginAdmins = { seo: pluginAdmin0 };
Rich text conversion
Portable Text fields use TipTap, which is based on ProseMirror. EmDash converts Portable Text to ProseMirror when the editor loads and converts it back to Portable Text when the entry is saved. Unknown blocks from plugins or imports are preserved as read-only placeholders instead of being discarded.
Signed uploads
Media uploads use direct-to-storage signed URLs when the storage adapter supports them and a same-origin streaming endpoint otherwise:
- The client requests an upload target from
POST /_emdash/api/media/upload-url. EmDash creates a pending media item. - The client uploads to the returned target. S3-compatible adapters can return a signed URL that bypasses application body-size limits; native R2 bindings and local storage return an EmDash streaming endpoint.
- The client confirms the upload with
POST /_emdash/api/media/:id/confirm. - EmDash validates the stored file and marks the media item as ready.
Extending the content importer
The WordPress importer uses a pluggable ImportSource interface. A source can probe a URL, analyze the available content against the current schema, and stream normalized content items:
interface ImportSource {
id: string;
name: string;
description: string;
icon: "upload" | "globe" | "wordpress" | "plug";
requiresFile?: boolean;
canProbe?: boolean;
probe?(url: string): Promise<SourceProbeResult | null>;
analyze(input: SourceInput, context: ImportContext): Promise<ImportAnalysis>;
fetchContent(input: SourceInput, options: FetchOptions): AsyncGenerator<NormalizedItem>;
fetchMedia?(url: string, input: SourceInput): Promise<Blob>;
}
The WXR source imports WordPress export files. The connector source imports directly from sites with the EmDash WordPress plugin. A separate REST source detects public WordPress sites, but directs the user to a WXR export because direct REST import is not implemented. Register another source when an importer can produce the same normalized analysis and content-item shapes.