How to Customize Content Models in EmDash CMS

How to Customize Content Models in EmDash CMS

EmDash's content model is the part of the platform closest to WordPress's mental model — collections work like post types, fields work like custom fields — but the implementation is fully typed and changeable at runtime with no code required for the schema itself. Here's how to actually define and evolve it.

Table of Contents
  1. The Core Idea: Change It Anytime, Type-Safe Throughout
  2. Creating a Collection
  3. The 16 Field Types
  4. Defining a Field: The Common Shape
  5. Reference Fields for Relational Content
  6. Reserved Field Slugs
  7. Portable Text: The Rich Content Field
  8. Exporting Your Model as a Reusable Seed
  9. Frequently Asked Questions
  10. Can a non-developer safely create new collections?
  11. What happens to existing content if I change a field's type?
  12. Should I use json or repeater for structured, repeating data?
  13. Do generated TypeScript types update automatically when I change a field?
  14. The Bottom Line

The Core Idea: Change It Anytime, Type-Safe Throughout

You define collections and fields — in the admin panel or with the CLI — and EmDash stores content against them. Add, rename, remove, or retype collections and fields whenever you need to; changes take effect immediately. Generate TypeScript types from the current model for autocomplete from query to template.

That combination — no-code schema changes plus generated types — is the specific thing to understand about EmDash's content model. A content editor with no development background can design a whole collection through the admin UI, and the moment they do, your Astro templates get autocomplete for the fields they just created.

Creating a Collection

  1. In the admin panel, navigate to the Visual Schema Builder.
  2. Create a new collection and give it a slug (e.g. `testimonials`).
  3. Add fields one at a time, choosing a type for each.
  4. Save — the collection and its fields are live immediately, with no redeploy required.

The same thing scripted via the CLI, useful for reproducible setups or CI:

npx emdash schema list
npx emdash schema get posts
emdashkits.com
Read also:

The 16 Field Types

Every field maps to a specific SQLite column type under the hood, which is worth knowing when you're deciding between similar-looking options:

  • string — short single-line text (TEXT). Titles, names, short values.
  • text — multi-line plain text (TEXT). Descriptions, excerpts.
  • slug — URL-safe identifier (TEXT), auto-sanitized: lowercased, spaces to hyphens, special characters stripped.
  • number — decimal values (REAL). Prices, ratings, measurements.
  • integer — whole numbers (INTEGER). Quantities, counts.
  • boolean — true/false (INTEGER 0/1). Toggles and flags.
  • datetime — ISO 8601 date and time (TEXT).
  • select — single choice from a fixed option list (TEXT).
  • multiSelect — multiple choices from a fixed option list (JSON array).
  • portableText — the rich text editor, block-based structured content (JSON).
  • image — reference to an uploaded image via the media library (TEXT/JSON, includes dimensions and alt text).
  • file — reference to an uploaded document or other file (TEXT/JSON).
  • reference — link to another collection's entry, single or with allowMultiple (TEXT or array).
  • json — arbitrary, unvalidated JSON for genuinely dynamic data.
  • repeater — a repeating group of sub-fields (JSON).

Defining a Field: The Common Shape

{
  slug: "price",
  label: "Price",
  type: "number",
  required: true,
  validation: {
    min: 0,
    max: 999999.99,
  },
}
emdashkits.com

Every field type accepts the same base properties: `slug` (the stored key, required), `label` (admin display name), `type`, `required`, `unique`, `defaultValue`, `validation` (type-specific rules), `widget` (override the default input UI), `options` (widget configuration), and `sortOrder` (admin display order).

Reference Fields for Relational Content

A `reference` field is how EmDash models relationships — an author linked to posts, a category linked to products:

{
  slug: "author",
  label: "Author",
  type: "reference",
  required: true,
  options: {
    collection: "authors",
  },
}
emdashkits.com

Set `allowMultiple: true` in `options` for a many-relationship (a post with several contributors, say) — the value is then stored as an array of entry IDs instead of a single one.

Reserved Field Slugs

A specific, easy-to-hit gotcha: certain slugs are reserved by EmDash's core schema and can't be reused for a custom field. If you name a field one of these, the schema builder will reject it:

  • id, slug, status, author_id, primary_byline_id
  • created_at, updated_at, published_at, scheduled_at, deleted_at
  • version, live_revision_id, draft_revision_id
  • terms, bylines, byline

If you're migrating a WordPress custom field named one of these, rename it during the import's field-mapping step rather than fighting the reserved name.

Portable Text: The Rich Content Field

The `portableText` field type is what gives EmDash structured (not raw-HTML) rich content — headings, lists, links, images, code blocks, and custom plugin-defined block types, stored as a JSON array of typed blocks rather than an HTML string:

[
  {
    "_type": "block",
    "style": "normal",
    "children": [{ "_type": "span", "text": "Hello world" }]
  }
]
emdashkits.com

That structure is exactly what makes EmDash content portable and queryable in ways raw HTML fields aren't — you can programmatically find every post referencing a specific link, or bulk-transform a block type, without parsing HTML.

Exporting Your Model as a Reusable Seed

Once you've built a content model you're happy with, export it as a portable JSON seed file — useful for version control, or bootstrapping a second project from the same starting structure:

npx emdash export-seed --with-content posts,pages > seed.json
emdashkits.com

The seed captures collections, fields, taxonomies, menus, widget areas, and settings, plus content if you request it — and a new EmDash site reads it automatically on first boot if placed at `.emdash/seed.json`.

Frequently Asked Questions

Can a non-developer safely create new collections?

Yes — that's a deliberate design goal. The Visual Schema Builder is admin-panel-only, no code required, and a content editor can design an entire collection structure themselves.

What happens to existing content if I change a field's type?

Retyping a field is supported, but incompatible value conversions (e.g., turning free text into a strict `select` with a fixed option list) can surface conflicts the admin UI will flag — review existing data before a type change on a field with real content in it.

Should I use json or repeater for structured, repeating data?

Prefer `repeater` when the shape is known and consistent (a list of FAQ question/answer pairs, for instance) since it gives you defined sub-fields and admin UI. Reserve `json` for genuinely freeform or third-party-integration data without a fixed structure.

Do generated TypeScript types update automatically when I change a field?

Types regenerate from your current schema — run the type-generation step (via the CLI's `--types` flag on `emdash dev`, or your project's configured flow) after a schema change to get updated autocomplete in your editor.

The Bottom Line

EmDash's content model is genuinely no-code to design and fully typed to consume — 16 field types cover most structured-content needs, reference fields handle relationships, and Portable Text keeps rich content queryable instead of opaque HTML. Once you've modeled something you like, export it as a seed and reuse it. See our guide to querying that content from Astro once your schema is in place.

Share

Comments

Write a comment

Related Articles

Login Works in Production but Fails on localhost: The secure Cookie Flag Gotcha

July 16, 2026

Login Works in Production but Fails on localhost: The secure Cookie Flag Gotcha

Google Analytics (GA4) Not Tracking Anything and No Console Errors: Check Your CSP First

July 16, 2026

Google Analytics (GA4) Not Tracking Anything and No Console Errors: Check Your CSP First

Native Module Build Fails on Shared Hosting (node-gyp, Old glibc/Python): The .npmrc Fix

July 16, 2026

Native Module Build Fails on Shared Hosting (node-gyp, Old glibc/Python): The .npmrc Fix

Login Works in Production but Fails on localhost: The secure Cookie Flag Gotcha

Login Works in Production but Fails on localhost: The secure Cookie Flag Gotcha

TL;DR — A custom session-cookie login flow appeared to succeed on localhost (the OTP verified, the response looked fine) but every subsequent request to a login-gated page treated the visitor as logged out. Identical code worked fine on the live HTTPS site. The cookie's Secure attribute was hardcoded to true — and per the cookie spec, browsers never store or send a Secure cookie over a plain, non-HTTPS connection.

Table of Contents
  1. Diagnostic
  2. Root cause
  3. Fix
  4. Lessons learned

Diagnostic

Check the actual Set-Cookie response header and the browser's own cookie storage panel — on localhost over http://, the cookie is sent by the server but never actually stored by the browser.

Root cause

// before -- assumes the app is always served over HTTPS
setCookie("session", token, { secure: true, httpOnly: true });
emdashkits.com

A cookie config that quietly assumes "we're always on HTTPS" breaks the instant you test over plain HTTP, which local dev servers commonly are.

Read also:

Fix

// after -- derive secure from the actual request protocol
const isHttps = request.url.startsWith("https://");
setCookie("session", token, { secure: isHttps, httpOnly: true });
emdashkits.com

Lessons learned

  • Any Secure-flagged cookie needs to key off the real request scheme, not an assumption baked in once at cookie-creation time.
  • "Works in production, silently fails in local dev" is a strong signal to check cookie flags before anything else in an auth flow.
  • Check other cookies in the same codebase for the same hardcoded assumption — if one cookie has this bug, sibling cookies set the same way are worth auditing too.
Share
Previous Article

Google Analytics (GA4) Not Tracking Anything and No Console Errors: Check Your CSP First

Comments

Write a comment

Related Articles

Google Analytics (GA4) Not Tracking Anything and No Console Errors: Check Your CSP First

July 16, 2026

Google Analytics (GA4) Not Tracking Anything and No Console Errors: Check Your CSP First

Native Module Build Fails on Shared Hosting (node-gyp, Old glibc/Python): The .npmrc Fix

July 16, 2026

Native Module Build Fails on Shared Hosting (node-gyp, Old glibc/Python): The .npmrc Fix

Uploaded Images Disappear After Every Deploy on Shared Hosting (and Other Reverse-Proxy Gotchas)

July 16, 2026

Uploaded Images Disappear After Every Deploy on Shared Hosting (and Other Reverse-Proxy Gotchas)

Google Analytics (GA4) Not Tracking Anything and No Console Errors: Check Your CSP First

Google Analytics (GA4) Not Tracking Anything and No Console Errors: Check Your CSP First

TL;DR — GA4's gtag.js snippet was installed correctly, the page loaded with no visible JavaScript error, and GA4's real-time report still showed zero activity. The CMS's default Content-Security-Policy had no allowance for analytics domains and no config option to add one — so every request to Google's tracking endpoints was blocked at the browser level before it could fail loudly.

Table of Contents
  1. Diagnostic
  2. Root cause
  3. Lessons learned

Diagnostic

Check the browser's dedicated CSP violation reporting, not the regular console error list — CSP blocks are reported through their own channel, not thrown as normal script errors, so "no console errors" doesn't mean nothing was blocked.

Root cause

The CSP's script-src and connect-src directives had no entry for googletagmanager.com or google-analytics.com, and the CMS exposed no configuration surface to add one — the only way in was patching the CSP directives directly.

// patch-package: add analytics domains to the existing CSP directives
scriptSrc.push("https://www.googletagmanager.com");
connectSrc.push("https://www.google-analytics.com", "https://www.googletagmanager.com");
emdashkits.com
Read also:

Lessons learned

  • "No console errors" is not proof nothing was blocked — CSP violations live in their own reporting surface and are easy to miss if you're only scanning for red error text.
  • Before adding any third-party script tag to a site with a CSP already in place, check the CSP's directives first rather than assuming a silently-empty analytics dashboard means a snippet-installation mistake.
Share
Previous Article

Native Module Build Fails on Shared Hosting (node-gyp, Old glibc/Python): The .npmrc Fix

Next Article

Login Works in Production but Fails on localhost: The secure Cookie Flag Gotcha

Comments

Write a comment

Related Articles

Login Works in Production but Fails on localhost: The secure Cookie Flag Gotcha

July 16, 2026

Login Works in Production but Fails on localhost: The secure Cookie Flag Gotcha

Native Module Build Fails on Shared Hosting (node-gyp, Old glibc/Python): The .npmrc Fix

July 16, 2026

Native Module Build Fails on Shared Hosting (node-gyp, Old glibc/Python): The .npmrc Fix

Uploaded Images Disappear After Every Deploy on Shared Hosting (and Other Reverse-Proxy Gotchas)

July 16, 2026

Uploaded Images Disappear After Every Deploy on Shared Hosting (and Other Reverse-Proxy Gotchas)