Contents
The uncertainty that started this whole thing was not technical. It was a business question wearing an engineering costume: would anyone actually pay to take spiritual-education courses online?
Tejgyan Foundation is a non-profit. Its teachers had spent years running courses, recording audios, and filming long-form video sessions, consumed mostly on phones, mostly in India, in Hindi and English. The content existed and the audience existed. What did not exist was any evidence that the audience would pull out a card and pay for that content packaged as an online platform. Nobody in the room knew. Including me.
That single admission — we do not know — is the most important thing I brought to the first architecture meeting. Because when you genuinely do not know whether the thing is worth building, the worst move you can make is to spend six months building it well.
The trap of building the real thing first
The instinct, especially for a strong engineering team, is to design the platform you will eventually need. You start sketching a Django service, a proper database schema, an auth system, a payment integration, an admin dashboard the content team can use, a video pipeline. Every one of those is a real requirement of the final product, so it feels responsible to start on them now.
It is not responsible. It is a bet — a large, illiquid, months-long bet — placed before you have seen a single card of the hand. If it turns out that people will not pay, or will only pay for the audio and ignore the video, or want weekly live cohorts instead of self-paced courses, then most of that carefully-built platform is wasted motion. You will have learned the one thing that mattered — what people want — at the highest possible price.
So I flipped the question. Instead of “what platform do we need to build,” I asked “what is the smallest thing we can ship that lets real users, spending real time, teach us the answer?” That thing is an MVP — a minimum viable product — and the word carrying all the weight is viable. Not a prototype, not a mockup. A real, working product that real users engage with, built with the absolute minimum of custom engineering. The entire point of an MVP is to buy information, and the currency you pay with is scope.
What a headless CMS actually is
Here is where the key technical decision came in, and it is worth teaching from first principles because it is the hinge the whole chapter turns on.
A traditional content management system — think classic WordPress — bundles two jobs into one program. Job one is the back office: a place where non-technical people log in, type content into forms, upload images, and hit save. Job two is the storefront: the actual public website that stitches that content into HTML pages and serves them to visitors. In a traditional CMS the two are welded together. The same software that stores your content also decides what the page looks like, using its own themes and templates.
A headless CMS keeps job one and throws away job two. “Headless” means it has no front-end head of its own — no themes, no page rendering. It keeps the back office where your content team works, and it keeps a database behind it. Then, instead of rendering pages, it exposes everything through an API — an Application Programming Interface, which is just a structured web address your own code can call to fetch content as raw data. You ask the API “give me course number 12,” and it hands back a tidy packet of data: title, description, lesson list, media URLs. What you do with that packet — render it as a website, feed it into a phone app, pipe it anywhere — is entirely up to you.
flowchart TD T[Traditional CMS] --> TW[Content plus website<br/>welded into one box] H[Headless CMS] --> HC[Content and admin only] HC --> A[API serves raw data] A --> F[Any front-end you build]
Why does that separation matter for an MVP? Because it hands you, for free and on day one, the two things that are genuinely expensive to build well: a database with a data model, and an admin panel that non-technical people can operate. Those are exactly the parts of the eventual platform I did not want to hand-build just to test a hypothesis. If a headless CMS gives me a running content API and a usable back office out of the box, then the only thing my team has to build is a thin, disposable front-end to display it. Weeks of work, not months.
The options on the table
Once “headless CMS” was the direction, the question narrowed to which one. Three candidates got real evaluation.
Contentful and the hosted SaaS crowd. Polished, reliable, and priced for venture-funded startups. For a non-profit trying to prove a hypothesis on a shoestring, the per-seat, per-API-call pricing was a non-starter, and the content lived on someone else’s servers under someone else’s terms. Ruled out on cost and control.
Strapi. The popular open-source, self-hostable choice, and genuinely good. Node.js based, big community, flexible content modelling, and a stack I was personally fluent in. It was the front-runner for a while. If I had optimised for my comfort, I would have picked it. Where it lost was on the two things that actually mattered for this team.
Directus. Also open-source and self-hostable, but with a different philosophy that turned out to be decisive.
The deciding factors were not developer ergonomics or GitHub stars. They were about the people who would run this thing every single day — the foundation’s content team, who are teachers and volunteers, not engineers. Put concretely, feature by feature:
- Database-first vs framework-first. Strapi owns its schema. You define content types in code and JSON, Strapi generates the tables it wants, and the shape of those tables is Strapi’s business — change a field and you are running a Strapi migration and rebuilding the admin bundle. Directus is the inverse: it introspects an existing Postgres database and exposes whatever tables and columns are already there. The database is the source of truth; Directus is a lens over it. For a throwaway I intended to migrate off, that difference is the whole game — my content would sit in plain Postgres tables I could read and reshape with SQL, not inside a framework’s internal representation.
- Admin usability for non-engineers. Directus renders each table as a clean, spreadsheet-like collection with sensible default detail views. Strapi’s content manager at the time was closer to a developer’s editor — perfectly usable if you think in content types, more friction if you are a teacher who thinks in “the list of courses.”
- Granular permissions in the UI. Directus ships role-, collection-, field-, and row-level permissions you configure by clicking, no redeploy. That is what let me hand the content team a locked-down role without writing an authorization layer.
- Insights without an engineer. Directus has a built-in dashboard/analytics panel that reads straight off the same tables, so the team could see which languages and formats pulled without me writing a query.
- Zero-config API surface. Both give you an API, but Directus auto-generates REST and GraphQL over every collection the moment you create it, with filtering, sorting, field selection, and relational expansion built in — no resolver code to write.
There is a real trade-off I am naming honestly: Directus binds you fairly tightly to its own conventions, and Strapi arguably gives developers more modelling freedom. In a greenfield platform meant to last five years, that freedom matters. In an MVP whose explicit job is to be outgrown and replaced, it does not. I optimised for the daily operator, not the future maintainer, because the future maintainer might never exist if the operators could not prove the hypothesis first.
And I never seriously considered the third option — hand-rolling a Django admin plus a content API myself. That is not a smaller amount of the same work; it is the entire expensive part of the final platform (schema, migrations, auth, an editing UI a volunteer can survive, a permissions model) built before I knew if any of it was worth building. The whole point was to not write that code yet.
The shape of what we shipped
The architecture was deliberately, almost embarrassingly, simple. Standing up the core was an afternoon of config, not a sprint:
# docker-compose.yml — the entire "backend," roughly
services:
database:
image: postgres:14-alpine
environment:
POSTGRES_DB: happy_thoughts
POSTGRES_USER: directus
POSTGRES_PASSWORD: ${DB_PASSWORD}
volumes:
- ./data/db:/var/lib/postgresql/data
healthcheck:
test: ["CMD", "pg_isready", "-U", "directus", "-d", "happy_thoughts"]
interval: 10s
retries: 5
directus:
image: directus/directus:latest
ports:
- "8055:8055"
volumes:
- ./data/uploads:/directus/uploads
environment:
# KEY and SECRET are required — Directus refuses to boot without them.
# Real values come from the environment, never the compose file.
KEY: ${DIRECTUS_KEY}
SECRET: ${DIRECTUS_SECRET}
PUBLIC_URL: https://cms.example.org
# Bootstraps the first admin on an empty database, then ignored.
ADMIN_EMAIL: ${ADMIN_EMAIL}
ADMIN_PASSWORD: ${ADMIN_PASSWORD}
DB_CLIENT: pg
DB_HOST: database
DB_PORT: 5432
DB_DATABASE: happy_thoughts
DB_USER: directus
DB_PASSWORD: ${DB_PASSWORD}
depends_on:
database:
condition: service_healthy
That was the whole shape of it. Postgres for content, Directus for the admin and the auto-generated API, and one thin Next.js front-end reading straight off the API. No custom auth, no bespoke content service, no dashboards I had to write.
flowchart LR T[Content team<br/>teachers and volunteers] D[Directus admin panel] DB[(SQL database)] API[Directus REST API] FE[Simple web front-end] U[Users on phones<br/>Hindi and English] T --> D --> DB DB --> API --> FE --> U
The data model, and why it stayed plain
Two collections carried the whole product: courses and lessons, one-to-many. In Directus you build these by clicking, but everything it does is reproducible and version-controllable through a schema snapshot — here is the meaningful slice of directus schema snapshot, trimmed to the fields that mattered:
collections:
- collection: courses
meta: { sort_field: sort, archive_field: status, archive_value: archived }
- collection: lessons
meta: { sort_field: sort, archive_field: status, archive_value: archived }
fields:
- { collection: courses, field: id, type: integer, schema: { is_primary_key: true, has_auto_increment: true } }
- { collection: courses, field: status, type: string, meta: { interface: select-dropdown, options: { choices: [draft, published, archived] } }, schema: { default_value: draft } }
- { collection: courses, field: sort, type: integer }
- { collection: courses, field: title, type: string }
- { collection: courses, field: slug, type: string, schema: { is_unique: true } }
- { collection: courses, field: language, type: string, meta: { interface: select-dropdown, options: { choices: [hi, en] } } }
- { collection: courses, field: description, type: text }
- { collection: courses, field: cover_image, type: uuid, meta: { interface: file-image, special: [file] } } # M2O -> directus_files
- { collection: lessons, field: id, type: integer, schema: { is_primary_key: true, has_auto_increment: true } }
- { collection: lessons, field: status, type: string, meta: { interface: select-dropdown, options: { choices: [draft, published, archived] } }, schema: { default_value: draft } }
- { collection: lessons, field: sort, type: integer }
- { collection: lessons, field: course, type: integer, meta: { interface: select-dropdown-m2o, special: [m2o] } } # FK -> courses.id
- { collection: lessons, field: title, type: string }
- { collection: lessons, field: audio, type: uuid, meta: { interface: file, special: [file] } }
- { collection: lessons, field: video, type: uuid, meta: { interface: file, special: [file] } }
- { collection: lessons, field: duration_s, type: integer }
relations:
- { collection: lessons, field: course, related_collection: courses, meta: { one_field: lessons } }
The reason this mattered for the escape hatch is that none of it is CMS-shaped magic. Under the covers courses is exactly the table you would have written by hand:
CREATE TABLE courses (
id serial PRIMARY KEY,
status varchar(255) NOT NULL DEFAULT 'draft',
sort integer,
title varchar(255) NOT NULL,
slug varchar(255) UNIQUE,
language varchar(255),
description text,
cover_image uuid REFERENCES directus_files(id)
);
When migration day came, that was not a CMS export to reverse-engineer. It was a Postgres table I could SELECT from straight into the Django models — which is exactly the escape hatch I picked Directus for.
The API you get for free
The moment those collections existed, Directus was serving a REST endpoint per collection with no resolver code from me. A single course came back as a clean JSON packet:
GET /items/courses/12?fields=id,title,slug,cover_image,lessons.title,lessons.audio
{
"data": {
"id": 12,
"title": "Introduction to Happy Thoughts",
"slug": "intro-happy-thoughts",
"cover_image": "6a1c...e9",
"lessons": [
{ "title": "What is a thought", "audio": "b23f...11" },
{ "title": "Watching the mind", "audio": "c98a...42" }
]
}
}
The real power — and, later, the real trap — was in the query string. Directus lets the caller shape the response, and these are the exact parameters the front-end leaned on:
# Only published courses, newest curated first, just the card fields:
/items/courses?filter[status][_eq]=published&sort=sort,-date_created&fields=id,title,slug,cover_image
# One course with only its published lessons expanded (deep filter on the relation):
/items/courses/12?fields=id,title,lessons.title,lessons.audio&deep[lessons][_filter][status][_eq]=published
# The convenient, lazy, expensive one — "just give me everything, nested":
/items/courses?fields=*.*
Those first two are the disciplined calls: fields= names exactly the columns the screen needs, filter[status][_eq]=published keeps drafts out of production without any code, and sort= orders in the database instead of the client. That last call — fields=*.*, which tells Directus to hydrate every column and expand every relation one level deep — is the one that felt wonderful on day one and quietly became the villain of the next chapter. When a course had forty lessons and each lesson dragged its full audio and video file records along, that single tidy-looking request turned into a pile of joins and a fat payload over Indian mobile data. Hold that thought; the fix — asking for sparse fields on purpose — is where chapter ten earns its keep.
Roles, so the content team never needed us
The other thing Directus handed over for free was authorization, and this is what let the team publish without an engineer in the loop. I created one Content Editor role next to the built-in Administrator, gave it app access but not admin access, and set permissions per collection by clicking — no auth code, no redeploy. Conceptually the grants looked like this:
[
{ "role": "content-editor", "collection": "courses", "action": "create", "fields": ["*"] },
{ "role": "content-editor", "collection": "courses", "action": "read", "fields": ["*"] },
{ "role": "content-editor", "collection": "courses", "action": "update", "fields": ["*"] },
{ "role": "content-editor", "collection": "lessons", "action": "create", "fields": ["*"] },
{ "role": "content-editor", "collection": "lessons", "action": "read", "fields": ["*"] },
{ "role": "content-editor", "collection": "lessons", "action": "update", "fields": ["*"] }
]
No delete on either collection — a volunteer could archive a course by flipping status to archived, but nobody could hard-delete content and orphan a course’s lessons. No permissions at all on directus_users, directus_roles, or settings, so the role was walled off from anything that could break the instance. And because “publish” was just status: published on a row they were already allowed to update, the entire editorial workflow — draft, review, publish — lived inside permissions the team controlled, while the public API stayed honest simply by always calling filter[status][_eq]=published. That is the whole reason my engineers stayed out of the content loop: the back office, the workflow, and the access control were configuration, not code I had to write and babysit.
I made two rules explicit with the team, and both mattered.
Rule one: the front-end is disposable, and we will say so out loud. Nobody was allowed to fall in love with the MVP front-end code or gold-plate it, because its job was to be thrown away the moment we had our answer. Naming a thing disposable up front is what stops a team from quietly turning a probe into a foundation.
Rule two: the content team owns the content, day one. Every hour an engineer spends entering content on behalf of a non-technical team is an hour stolen from the actual question. Because Directus gave the operators a back office they could run themselves, my team stayed out of the content loop entirely and spent its time on the one thing only engineers could do.
What we deliberately left out
No payments. That was a conscious cut, not an oversight. You validate that people consume before you ask whether they will pay — reversing that order is how you build a checkout for a store no one walks into. No transcoding, no recommendations, no native app. The MVP’s entire job was to answer one question, and everything that did not serve that question was noise.
The outcome
We went from decision to a live, working platform in a matter of weeks rather than months. Real users signed up. Real courses got consumed on real phones across India. And the number that mattered started to move: we reached roughly 2,000 users on this first stack.
That two thousand was never the goal in itself. The goal was the learning it bought us. People came back the next day, people finished long talks, and the built-in Directus reports — reading straight off those same courses and lessons tables — showed a real shape to what pulled: which languages, which formats, which themes. The bet was not just “yes.” It came with a map of the real product, drawn from watching people use the disposable one. And it set up the very next question the platform had to answer: whether these users would pay.
The lesson
Two things carry forward from this chapter, and I have applied both on every product since.
Ship to learn, not to launch. When the central uncertainty is about people — will they show up, what do they actually want — no amount of architecture answers it. Only shipping does. Treat the first version as an instrument for buying information, and pay for that information in scope, not in months. Design it to be thrown away and you will make cheaper, braver, faster calls, because you are running an experiment, not defending a monument. The best thing an MVP can do is become obsolete because it worked.
Pick the tool your daily operators can run, not the one your engineers admire. The most consequential technical call here — Directus over Strapi — was not decided on a technical axis at all. It was decided by watching a non-technical volunteer use both. When the humans running a tool every day are not engineers, admin-panel usability is a technical requirement, not an afterthought. The best framework in the world is worthless if the operators cannot run it without you.
The MVP did its job perfectly. And then, as every honest MVP does the moment it succeeds, it started to crack under the weight of that success — those convenient fields=*.* responses out of Directus turning slow, and nowhere clean to put a payment flow. That is where the next chapter begins.
