Content Modeling

How We Designed Draftbase's Content Model

We built our content model around entries, templates, and revisions. Here is what we got right, what we would redo, and why.

SA
Samer Alsayegh
Founder
Published
4 min read
Flat vector illustration of a content model schema diagram with interconnected field-type nodes linked into a template blueprint
Key takeaways

Draftbase stores every entry as one validated field blob instead of fixed columns, so user-defined content types don't need migrations. The one thing we'd redo: the field type list is hand-kept in sync across two files instead of one shared source.

Every entry in Draftbase is one Mongo document with a single fields blob, not a column per field. We validate that blob against a template's schema on every write. We don't enforce it at the database level at all. That's the core decision behind our content model. This post covers what it bought us, what it cost us, and the one piece we'd build differently if we started over.

Why a Blob Instead of a Column Per Field

A content type in Draftbase is user-defined. A customer can add a field to a template on Tuesday and expect existing entries to keep working. A fixed relational schema fights that. Every new field is a migration, and a migration on a large entries table is downtime risk on someone else's content, not just ours.

So an entry stores fields: Record<string, unknown>, and a template stores the shape that blob is supposed to match. validateFields runs on create and update, checking every field against its template's type before the write commits. Add a field to a template, and every entry that predates it just doesn't have that key yet — no backfill required, no locked table. The database enforces almost nothing here. The application does.

The Field Type List We'd Redo

Eight field types cover everything in Draftbase: text, richText, number, boolean, date, media, reference, json. Modifiers like list, pattern, and referenceTemplateId layer on top instead of forking into new types. A "list of references" is a reference field with list: true, not a ninth type.

Here's the part we'd change. That eight-value list is defined twice: once in the backend's template model, once in the SDK's type generator, kept in sync by hand. Nothing imports one from the other. Add a ninth field type today, and it's two edits in two packages, with no compiler error if you only remember one of them. It's worked so far because field types change rarely. It's still the kind of thing that looks fine, until the day someone forgets the second file. Then a generated SDK type quietly falls out of sync with what the API actually accepts. A shared package exporting one source of truth is the obvious fix. We haven't done it, because nothing has broken yet — which is exactly the condition under which this kind of drift usually ships.

Revisions: We Traded Precision for Signal

Every write to an entry snapshots the field values into a separate entryRevisions collection, so rollback works and history is real. The naive version of that snapshots on every single save. It sounded right, until we watched what it produced. A user fixing a typo across three keystrokes generates three saves, and three near-identical revisions nobody will ever want to diff.

We added a five-minute rolling merge window instead. Saves inside five minutes of the last one collapse into the same revision instead of stacking a new one. Version numbers only go up, and rollback restores a prior snapshot as a new version, not a hard revert. The tradeoff is real: an editor who wants to see exactly what changed between keystroke two and keystroke three of a single editing session can't. Nobody has asked for that yet. Fewer, more meaningful revisions were worth losing a granularity nobody uses.

References Resolve at Read Time, Not Write Time

A reference field stores a plain entry _id string. Nothing more. Resolving that id into the actual referenced entry — title, fields, its own nested references — happens at read time, not when the reference is saved. That's a deliberate choice: it means an author entry's bio can change without touching every blog post that references it. Denormalizing at write time would mean either a fan-out update to every referencing entry, or content silently drifting stale until someone re-saves it.

The cost shows up in read latency and in cycle risk. A template can reference itself, so we cap resolution depth at five levels. We also batch one query per depth level instead of one query per reference. A self-referencing category tree doesn't turn into N+1 queries, and it can't resolve forever into a loop. referenceTemplateId on a field can scope which template a reference is allowed to point to, but that scoping is app-level only. Nothing in Mongo stops a mismatched id from being written. The check runs in validateFields, the same place every other field type gets checked, on the same schema-on-write pass.

What We'd Actually Change

Not the blob-over-columns call. Flexible schema for user-defined content types is the right tradeoff, and the read-time reference resolution holds up the same way. The real regret is smaller and more boring: one field-type source of truth instead of two files that happen to agree today. It's the kind of fix that's a half-day of work and zero user-visible change, which is exactly why it keeps losing to whatever's actually on fire that week.

Deciding between a fixed schema and a validated blob for your own content type system? The blob wins the moment your users, not your team, define the shape of their own content. See schema-driven content modeling for how the template layer validates all of this. Or check pricing to see the field types in a real editor.

Ship content that's built to be found

Draftbase generates schema, structured data, and a fast MDX editor for every post.

Frequently asked questions

Does Draftbase enforce field types at the database level?

No. Entries store fields as one JSON blob. Types are validated in application code on every write, not by the database.

Why not use a fixed relational schema for content types?

Users define their own content types. A fixed schema turns every new field into a migration. A validated blob doesn't.

How does Draftbase decide when to save a new revision?

Edits inside a five-minute window collapse into one revision instead of stacking a new one per keystroke. Rollback restores a saved snapshot as a new version.

Are reference fields resolved when content is saved or when it's read?

At read time. A reference field stores a plain entry id. Draftbase resolves it into the full entry when a page fetches the content.

What would the Draftbase team change about the content model?

One thing: the field type list is defined in two separate files by hand, not from one shared source. It has not broken yet, but it is the kind of drift that eventually will.

SA
Samer Alsayegh
Founder at Draftbase

Samer is a software engineer and entrepreneur, founder of Draftbase and Ezi Home Services, building technology that simplifies home services. Passionate about software, APIs, automation, and creating products that solve real-world problems.

content-modelingengineeringarchitecture

Related posts

Draftbase is a headless CMS built for React devs.