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.
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
| Strapi | TinaCMS | |
|---|---|---|
| Content lives in | A database (Postgres, MySQL, SQLite) | Markdown/MDX files in your repo |
| Content is versioned by | Whatever you build | Git, natively |
| Editing surface | Self-hosted admin panel | Visual editing on the live page |
| Content reaches the site via | REST or GraphQL at runtime | The build reads the files |
| Extra infrastructure | An app server plus a database | None beyond your repo and host |
| Publishing latency | Seconds — no deploy | A build and deploy |
| A non-developer can publish | Yes | Yes, through the editor |
| Content model changes | Content-Type Builder, then migrate | Edit the schema and the files |
| Preview of unpublished work | Draft state in the database | A branch |
| Rollback | What you built, or database restore | git revert |
| Self-hosting required | Yes (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:
src/data/blog.ts— the typed metadata registry (slug, title, description, date, cluster, target keyword, reading time, related products).src/content/blog/<slug>.mdx— the prose.postLoadersinsrc/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
| Symptom | Cause | Fix |
|---|---|---|
| Tables render as literal pipe characters | GFM plugin dropped by the Rust MDX pipeline | Name plugins as strings, not imported functions |
| A published post 404s in production only | Wildcard dynamic import turned a typo into a runtime failure | Use an explicit slug-to-import map so it fails at build |
| Editors are blocked whenever CI is red | Publishing is coupled to deploying — inherent to git-backed content | Keep the pipeline fast and green, or move to an API-backed CMS |
| Strapi admin reachable from the public internet | Default deployment exposes the panel | Restrict by network or SSO before the first editor account |
| Content model change means touching hundreds of files | Files have no migration layer | Write a codemod; treat schema changes as code changes |
| Preview of unpublished content requires a deploy | No draft state in a files model | Use 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.
