A client sent us a Postgres dump last spring: 41 tables, a Rails app writing to eleven of them, two nightly cron jobs, and a Stripe webhook that had been inserting rows since 2021. They wanted a headless CMS on top. Their previous agency had already started modeling the same orders and customers a second time inside Strapi, with a sync job planned for "phase two." That sync job is the failure. Nobody ever writes it well, and six months in you have two tables named customers that disagree about who churned.
Strapi and Directus are both good. We ship both, and the choice almost never turns on the feature grid — the grids converge. It turns on three things that only surface after launch: who owns the database schema, whether row-level access control is a config row or a file you maintain forever, and what happens the first time staging and production disagree about a column. Get those right and everything else is preference.
Schema ownership is the actual fork in the road
Strapi generates and owns its tables. Content types live as schema.json files under src/api, committed to git, and Strapi turns those into tables plus its own join tables for relations — components land in their own tables with a component_type and order column you did not ask for. You do not hand-edit those tables, and you do not point Strapi at a database another system already writes to. That ownership pays off on greenfield: relations, draft and publish, i18n and media come pre-wired and consistent, and a new developer can read the whole data model out of git without opening the admin.
Directus goes the other way. It introspects an existing SQL database and keeps presentation metadata — field interfaces, display names, sort order, validation — in its own directus_ prefixed tables, leaving your business tables untouched. Point it at that 41-table dump and you have an admin UI plus REST and GraphQL over it in an afternoon, with the Rails app still writing to the same rows. Strapi would ask you to model all of it twice and then live with two sources of truth. That single fact settles roughly half the Strapi-versus-Directus questions we get asked.
Row-level permissions: configuration or code
Here the comparison stops being a tie. Nearly every B2B project we take needs one rule: this user sees only their own organization's records. In Directus that is a permission row on the role with a filter — the record's tenant field equals the current user's tenant, expressed with $CURRENT_USER. It compiles into the query, so it holds for REST, GraphQL, nested reads through a relation, and the admin app identically. You configure it once and write tests against the config.
In Strapi's community edition, the Users & Permissions plugin is route-level: this role may or may not call this endpoint. Ownership and tenancy are yours to enforce in policies, custom controllers, or filters injected into a service — and you must remember to write them for every route you add, including the export endpoint someone ships in month nine under deadline. That is exactly how tenant data leaks: not a broken rule, a route with no rule. Field-level and conditional rules live in the paid tier. None of this makes Strapi wrong; it makes it a framework with a CMS attached, which is what you want when the rules are genuinely custom. It is the wrong trade when the rules are boring and just need to hold everywhere.
The migration story nobody tests until it hurts
Strapi alters the database from your schema files on boot in development, and the Content-Type Builder is disabled when NODE_ENV is production. That is the correct design — schema changes travel through git and a deploy, not through someone clicking in the production admin at 11pm the night before a launch. The sharp edge is renaming. A rename is a drop plus an add unless you intervene, and the data in the old column does not follow it; you get a fresh null column and a silent loss. We copy the content in an explicit migration script that ships before the rename, every time, and we test it against a restored production dump rather than seed data.
Directus allows schema changes in the admin UI in production, which feels great right up until two environments disagree. The discipline is the CLI: `directus schema snapshot` to a YAML file, commit it, `directus schema apply` on the target as a deploy step. Teams that skip it end up with a staging database that no longer resembles production and no record of how it drifted — the field exists in one place, was renamed in the other, and the only witness is whoever clicked. Both tools have a workable answer. The failure modes differ, and you should pick the one your team will actually avoid.
What the API feels like from the frontend
Strapi v5 flattened the response shape and moved to documentId as the public identifier, which deleted most of the old data/attributes wrapper boilerplate — and broke every v4 frontend that destructured it. Relations stay opt-in: you get nothing nested unless you populate, and populate=* only reaches one level, so a deep tree means naming each path. Frontend developers coming from an API that returns everything hate this for a week, then stop, because list endpoints stay small by default instead of quietly serializing an entire object graph into a category page.
Directus lets you request the exact field tree you want, filter and sort on nested fields, and hit aggregation endpoints without writing one. The trap is the other direction: a lazy request with several levels of wildcards fans out into a query per relation and turns one page load into dozens. Either way, a public site should read through a cache or a static build rather than hitting the CMS per page view. Neither of these is a read-optimized API, and neither claims to be.
How we choose, in one paragraph
If a real database already exists and other systems write to it, Directus. If you need per-record, per-tenant rules and don't want to maintain policy code for the life of the product, Directus. If it's greenfield, content-heavy, editorial workflow matters, and your team wants controllers and services in TypeScript in the same repo as the frontend, Strapi. If you need real business logic on save — charge a card, call an ERP, reject the write — Strapi wins on the escape hatch. One input engineers forget and finance does not: Strapi's core is MIT with capabilities held behind a paid tier, while Directus moved to a Business Source License that is free below a revenue threshold and paid above it. Read the current terms against your company's actual revenue before you standardize, because relicensing a CMS after two years of content modeling is not a weekend job.