Skip to content

Content Model

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 code
const posts = collection({
fields: {
title: text({ required: true }),
content: richText(),
},
});

EmDash stores this same information in database tables:

-- _emdash_collections table
INSERT INTO _emdash_collections (slug, label)
VALUES ('posts', 'Blog Posts');
-- _emdash_fields table
INSERT 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:

SourceDescription
manualCreated via admin UI
template:blogCreated by a template’s seed file
import:wordpressImported from WordPress
discoveredAuto-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:

  1. Inserts a record into _emdash_fields 2. Runs ALTER TABLE ec_collection ADD COLUMN column_name TYPE 3. Regenerates the Zod schema for validation

SQLite supports these ALTER TABLE operations at runtime:

OperationSupported
Add columnYes
Rename columnYes
Drop columnYes (SQLite 3.35+)
Change column typeNo (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:

ConcernLocationTables
SchemaSystem tables_emdash_collections, _emdash_fields
ContentPer-collection tablesec_posts, ec_products, etc.
MediaSeparate table + storagemedia table + R2/S3
SettingsOptions tableoptions with site: prefix

This separation means:

  • Schema can be exported without content
  • Content can be migrated between schemas
  • System tables are never cluttered with user data

EmDash builds Zod schemas from database field definitions at startup:

// Simplified example
function 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:

Terminal window
# Fetch schema from database, generate types
npx emdash types

This 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 functions
declare 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:

Terminal window
# Fetch schema, generate types
npx emdash types
# Export schema as JSON
npx emdash export-seed > seed.json

Non-developers use the admin UI exclusively:

  1. Open Content Types in the admin panel
  2. Click Add Collection
  3. Define fields through the visual builder
  4. Start creating content immediately

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 first
const { valid, errors } = validateSeed(seedData);
// Apply (idempotent - safe to re-run)
await applySeed(db, seedData, {
includeContent: true,
onConflict: "skip", // 'skip' | 'update' | 'error'
});
ApproachSchema LocationRuntime ModificationType Safety
EmDashDatabaseYes (full)Generated from DB
WordPressPHP code + EAVLimited (meta fields)None
StrapiCode filesNo (rebuild required)Generated at build
SanityCode filesNo (schema must deploy)Built-in
DirectusDatabaseYes (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.