Runtime Modification
Create and edit content types without code changes or rebuilds. Non-developers can design their data model through the admin UI.
EmDash uses a database-first content model where schema definitions live in the database, not in code. This is a fundamental design choice that enables runtime schema modification and non-developer-friendly setup.
Traditional CMSs like Strapi or Keystatic require you to define schema in code:
// Traditional approach - schema in codeconst posts = collection({ fields: { title: text({ required: true }), content: richText(), },});EmDash stores this same information in database tables:
-- _emdash_collections tableINSERT INTO _emdash_collections (slug, label)VALUES ('posts', 'Blog Posts');
-- _emdash_fields tableINSERT INTO _emdash_fields (collection_id, slug, type, required)VALUES ('coll_abc', 'title', 'string', true), ('coll_abc', 'content', 'portableText', false);Both approaches define the same content structure. The difference is where that structure lives and how it can be modified.
Runtime Modification
Create and edit content types without code changes or rebuilds. Non-developers can design their data model through the admin UI.
Real SQL Columns
Unlike WordPress’s EAV (Entity-Attribute-Value) model, each field gets a real column. Proper indexing, foreign keys, and query optimization.
Self-Documenting
Database tools can inspect schema directly. No need to parse code to understand the data model.
Migration Path
Export schema as JSON for version control. Import schema in new environments.
Two system tables define your content structure:
CREATE TABLE _emdash_collections ( id TEXT PRIMARY KEY, slug TEXT UNIQUE NOT NULL, -- "posts", "products" label TEXT NOT NULL, -- "Blog Posts" label_singular TEXT, -- "Post" description TEXT, icon TEXT, -- Lucide icon name supports JSON, -- ["drafts", "revisions", "preview"] source TEXT, -- How it was created created_at TEXT DEFAULT CURRENT_TIMESTAMP, updated_at TEXT);The source field tracks how the collection was created:
| Source | Description |
|---|---|
manual | Created via admin UI |
template:blog | Created by a template’s seed file |
import:wordpress | Imported from WordPress |
discovered | Auto-discovered from existing data |
CREATE TABLE _emdash_fields ( id TEXT PRIMARY KEY, collection_id TEXT REFERENCES _emdash_collections(id), slug TEXT NOT NULL, -- Column name: "title", "price" label TEXT NOT NULL, -- Display label type TEXT NOT NULL, -- Field type column_type TEXT NOT NULL, -- SQLite type: TEXT, REAL, INTEGER, JSON required INTEGER DEFAULT 0, unique_field INTEGER DEFAULT 0, default_value TEXT, -- JSON-encoded default validation JSON, -- Validation rules widget TEXT, -- Custom widget identifier options JSON, -- Widget options sort_order INTEGER, created_at TEXT DEFAULT CURRENT_TIMESTAMP, UNIQUE(collection_id, slug));Each collection gets its own table with the ec_ prefix. When you create a “products” collection with title and price fields:
CREATE TABLE ec_products ( -- System columns (always present) id TEXT PRIMARY KEY, slug TEXT UNIQUE, status TEXT DEFAULT 'draft', 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 field definitions) title TEXT NOT NULL, price REAL);When you add a field via the admin UI, EmDash:
_emdash_fields 2. Runs ALTER TABLE ec_collection ADD COLUMN column_name TYPE 3. Regenerates the Zod schema for validationSQLite supports these ALTER TABLE operations at runtime:
| Operation | Supported |
|---|---|
| Add column | Yes |
| Rename column | Yes |
| Drop column | Yes (SQLite 3.35+) |
| Change column type | No (requires table rebuild) |
For type changes, EmDash handles the table rebuild transparently: create new table → copy data → drop old table → rename new table.
EmDash maintains a clear separation:
| Concern | Location | Tables |
|---|---|---|
| Schema | System tables | _emdash_collections, _emdash_fields |
| Content | Per-collection tables | ec_posts, ec_products, etc. |
| Media | Separate table + storage | media table + R2/S3 |
| Settings | Options table | options with site: prefix |
This separation means:
EmDash builds Zod schemas from database field definitions at startup:
// Simplified examplefunction buildSchema(fields: Field[]): ZodSchema { const shape: Record<string, ZodType> = {};
for (const field of fields) { let zodType = fieldTypeToZod(field.type);
if (field.required) { zodType = zodType.required(); }
if (field.validation?.min !== undefined) { zodType = zodType.min(field.validation.min); }
shape[field.slug] = zodType; }
return z.object(shape);}Content is validated against these runtime schemas on every create and update operation.
Generate TypeScript types from your database schema:
# Fetch schema from database, generate typesnpx emdash typesThis generates .emdash/types.ts:
// .emdash/types.ts (generated)export interface Post { title: string; content: PortableTextBlock[]; excerpt?: string; featuredImage?: string;}
export interface Product { title: string; price: number; quantity: number;}
// Typed overloads for query functionsdeclare module "emdash" { export function getEmDashCollection(type: "posts"): Promise<ContentEntry<Post>[]>;
export function getEmDashEntry( type: "products", id: string, ): Promise<ContentEntry<Product> | null>;}Developers can use the CLI:
# Fetch schema, generate typesnpx emdash types
# Export schema as JSONnpx emdash export-seed > seed.jsonNon-developers use the admin UI exclusively:
Both approaches modify the same underlying database tables.
Templates and exports use JSON seed files for portable schema definitions:
{ "version": "1", "collections": [ { "slug": "posts", "label": "Blog Posts", "labelSingular": "Post", "supports": ["drafts", "revisions", "preview"], "fields": [ { "slug": "title", "type": "string", "required": true }, { "slug": "content", "type": "portableText" }, { "slug": "featuredImage", "type": "image" } ] } ], "taxonomies": [{ "name": "category", "label": "Categories", "hierarchical": true }], "menus": [{ "name": "primary", "label": "Primary Navigation" }]}Apply seed files programmatically:
import { applySeed, validateSeed } from "emdash/seed";import seedData from "./.emdash/seed.json";
// Validate firstconst { valid, errors } = validateSeed(seedData);
// Apply (idempotent - safe to re-run)await applySeed(db, seedData, { includeContent: true, onConflict: "skip", // 'skip' | 'update' | 'error'});| Approach | Schema Location | Runtime Modification | Type Safety |
|---|---|---|---|
| EmDash | Database | Yes (full) | Generated from DB |
| WordPress | PHP code + EAV | Limited (meta fields) | None |
| Strapi | Code files | No (rebuild required) | Generated at build |
| Sanity | Code files | No (schema must deploy) | Built-in |
| Directus | Database | Yes (full) | Generated from DB |
EmDash follows the Directus model: database-first with optional type generation. This provides maximum flexibility while still supporting type-safe development when desired.
Collections
Learn about field types and validation.
Admin Panel
Explore the admin architecture.
Seeding
Set up sites with seed files.