Skip to main content
ASoc
Comparison

Strapi vs TinaCMS: Content in a Database, or Content in the Repo

One axis decides it. What content-in-git actually costs, measured at 282 posts — including the bundler defect that only exists on that side of the line.

The ASoc Team9 min read

Strapi and TinaCMS are both open-source and both called headless, but they disagree about the one thing that matters: where content lives. Strapi puts it in a database behind an API you host. Tina puts it in Markdown files in your git repository and edits them in place. Everything else — hosting bill, preview story, who can publish — follows from that.

We don't run either. This blog runs the git-backed model without Tina: 282 MDX files committed beside the code, a typed metadata registry, and a test suite that fails the build when they drift. That means we can't tell you what Tina's editor feels like, but we can tell you precisely what content-in-git costs at 282 posts, which is the part of the decision the comparison pages skip.

The comparison

StrapiTinaCMS
Content lives inA database (Postgres, MySQL, SQLite)Markdown/MDX files in your repo
Content is versioned byWhatever you buildGit, natively
Editing surfaceSelf-hosted admin panelVisual editing on the live page
Content reaches the site viaREST or GraphQL at runtimeThe build reads the files
Extra infrastructureAn app server plus a databaseNone beyond your repo and host
Publishing latencySeconds — no deployA build and deploy
A non-developer can publishYesYes, through the editor
Content model changesContent-Type Builder, then migrateEdit the schema and the files
Preview of unpublished workDraft state in the databaseA branch
RollbackWhat you built, or database restoregit revert
Self-hosting requiredYes (Strapi Cloud is the managed option)The editor backend, or Tina Cloud

Read the table top-down and it's the same fact restated eleven times. That's the tell that this is a one-axis decision.

The axis: content in a database or content in the repo

Strapi is an application you run. You define content types in an admin UI, it provisions the tables, and it serves REST and GraphQL endpoints your frontend fetches from. Content changes the moment someone hits publish, because the site reads the database at request time (or revalidates against it). The cost is that you now operate a second application and its database: deployment, upgrades, backups, connection limits, and an admin panel exposed to the internet that needs its own access control. That is a real ops surface, and it is the honest price of decoupling publishing from deploying.

Tina is a layer over files you already have. Content is Markdown or MDX in the repo, so the site has no runtime content dependency at all — the build reads the filesystem. Tina's contribution is an editing experience on top of that: a schema, a sidebar, and click-to-edit on the live page, so an editor never touches the repository directly. Commits happen under the hood. The cost is that publishing is a deploy, and that the content model is only as enforced as your schema and your CI make it.

Everything below is what we learned running the second model without the editor.

What content-in-git actually costs, measured at 282 posts

Three files have to agree for every post on this site:

  1. src/data/blog.ts — the typed metadata registry (slug, title, description, date, cluster, target keyword, reading time, related products).
  2. src/content/blog/<slug>.mdx — the prose.
  3. postLoaders in src/lib/blog.ts — an explicit slug-to-import map.

That third file is the interesting one, because the obvious implementation is a wildcard dynamic import and we deliberately didn't write it:

const postLoaders: Record<string, () => Promise<{ default: ComponentType }>> = {
  "strapi-vs-headless-wordpress": () =>
    import("@/content/blog/strapi-vs-headless-wordpress.mdx"),
  "strapi-vs-payload-cms": () =>
    import("@/content/blog/strapi-vs-payload-cms.mdx"),
  // ...280 more
};

280 more lines of boilerplate buys one thing: a typo in a slug becomes a build error instead of a runtime 404. A wildcard dynamic import built from a template-literal path would have been three lines, and would fail in production instead — on a page nobody opened before the deploy.

The same instinct covers the rest. npm test fails if a post has no MDX file, if an MDX file has no registry entry, if a description would truncate in a meta tag, if a date isn't a real calendar date, or if a post links to a product slug that no longer exists in the catalog. This is the work a CMS does for you in its schema layer, and in a files-in-git setup it is work you write. At 282 posts it's about a hundred lines of tests, and it has caught every one of those mistakes at least once.

That is the honest accounting: content-in-git removes a database and an app server, and hands you back the validation those would have enforced.

The defect that only exists in the git-backed model

Our MDX pipeline runs remark-gfm for tables and rehype-slug for heading IDs, configured in next.config.ts:

const withMDX = createMDX({
  options: {
    remarkPlugins: ["remark-gfm"],
    rehypePlugins: ["rehype-slug"],
  },
});

The plugins are named as strings, not imported functions. This looks like a style choice and isn't. Turbopack runs the MDX pipeline in Rust, so a plugin passed as an imported JavaScript function cannot cross that boundary — and it is dropped silently. Nothing errors. The build passes. Every comparison table in every post renders as literal pipe characters, and you find out by looking at a page.

Nobody hits this bug with Strapi, because rich text arrives from an API already structured. It is a fair example of the class of problem you take on: in the git-backed model your content pipeline is part of your build, so your content can break in your bundler.

The question that actually decides it

Not "which is more flexible." This one: when a marketer needs a typo fixed on a Friday afternoon, what has to happen?

With Strapi, they log in and fix it. With a git-backed CMS, a build runs. Tina closes most of that gap — the editor commits for them, so no one needs to know what a pull request is — but the change still isn't live until the deploy finishes, and if the build is red for an unrelated reason, the typo stays.

Then ask the second question: how often does the content model change? Strapi's Content-Type Builder makes adding a field a UI action with a migration behind it. In files, adding a field means editing a type and touching every entry that needs it — which is a codemod or an afternoon, depending on your count. We changed the post registry's shape three times while it held under a hundred entries; doing it at 282 would be a script, not an afternoon.

Choose Strapi when content changes independently of code, multiple non-technical editors publish daily, several frontends or apps consume the same content, or the model is genuinely relational — products with variants, events with venues, authors with bios.

Choose Tina when content ships with the code that renders it, the people writing are the people deploying (or are happy in a visual editor), you want content reviewed in pull requests alongside code, and you'd rather not operate a database to publish a blog post.

Choose plain MDX with no CMS — what this site does — when everyone who writes can open a pull request and you'd rather spend the setup budget on tests than on an editor. That's a narrow case, and it stops being the right answer the moment one person who can't use git needs to publish. See MDX vs a headless CMS for that decision in full, and Sanity vs TinaCMS for Tina against a hosted content store rather than a self-hosted API.

Troubleshooting

SymptomCauseFix
Tables render as literal pipe charactersGFM plugin dropped by the Rust MDX pipelineName plugins as strings, not imported functions
A published post 404s in production onlyWildcard dynamic import turned a typo into a runtime failureUse an explicit slug-to-import map so it fails at build
Editors are blocked whenever CI is redPublishing is coupled to deploying — inherent to git-backed contentKeep the pipeline fast and green, or move to an API-backed CMS
Strapi admin reachable from the public internetDefault deployment exposes the panelRestrict by network or SSO before the first editor account
Content model change means touching hundreds of filesFiles have no migration layerWrite a codemod; treat schema changes as code changes
Preview of unpublished content requires a deployNo draft state in a files modelUse a branch preview, or a CMS with real draft status

Frequently asked questions

Is TinaCMS just Markdown files with extra steps? The files are the point, not a workaround. What Tina adds is an editing surface over them, so someone who has never opened a terminal can change a headline on the live page and have it land as a commit. If everyone writing is already comfortable in the repo, you may not need that layer — which is the position this site is in.

Can Strapi store content in git instead? Not the content itself. Strapi's content lives in its database; what you can version is the schema and configuration. If versioning the content is a requirement — because you want editorial changes reviewed like code — that requirement points at the git-backed model.

Which is cheaper to run? Files, almost always, because a git-backed site has no content infrastructure to pay for. Strapi needs an always-on app server and a database. The comparison flips if you count staff time: an editor waiting on a developer is more expensive than a small database.

Can I start with files and move to Strapi later? Yes, and it's the usual direction. Markdown with front matter maps cleanly onto content types, so the migration is a script. Going the other way is harder — you have to recreate in files what the database was enforcing, which is the test suite described above.

Templates in this post

ASoc Press is a blog, news and magazine template — the content-heavy case this decision usually gets made for. ASoc Nexus and ASoc Nimbus are SaaS marketing sites. All three are Next.js + Tailwind and ship content as typed files, ready to wire to either model.

Browse the full sets: Next.js landing page templates, Tailwind landing page templates.

Keep reading

Comparison9 min read

Supabase vs. AWS: What Five Services Replace One Dependency

Supabase bundles Postgres, auth, storage and atomic RPCs behind one SDK. Mapped against this app's real files, here's what RDS, Cognito, S3 and Lambda would cost to assemble instead.

Read more
Comparison9 min read

Supabase vs. Convex: A Lock You Write vs. a Transaction You Get

Convex's transaction model would have prevented a real TOCTOU race this storefront hit in its Postgres download limiter. Why that's true, and why the fix stayed on Supabase anyway.

Read more