Sanity vs TinaCMS: Hosted Content Store vs Content in Git
The axis is where content lives. Plus the case where neither wins — 269 MDX articles and 470k words here run on a typed registry and a test, no CMS.
Sanity and TinaCMS answer the same question differently: where does content live? Sanity keeps it in a hosted content store you query with GROQ. Tina keeps it as Markdown and JSON files in your git repository, edited visually on the live page. That single difference decides versioning, preview, cost and who can safely edit.
The short answer
Choose Sanity when several editors work on structured, relational content that outlives any one site, and you want real-time collaboration and a hosted API. Choose TinaCMS when content is prose that belongs beside the code, your team already reviews changes in pull requests, and you want non-developers editing by clicking the page rather than filling a form.
The comparison that matters
| Sanity | TinaCMS | |
|---|---|---|
| Where content lives | Hosted content store (Sanity's Content Lake) | Markdown/MDX/JSON files in your git repo |
| Query language | GROQ (and GraphQL) | GraphQL over the local/hosted data layer |
| Schema definition | TypeScript/JavaScript schema files | TypeScript config describing collections and fields |
| Editing model | Sanity Studio — a structured form-based editor | Visual, click-the-page inline editing plus forms |
| Versioning | Document history inside the platform | Git history — commits, branches, blame, reverts |
| Preview | Draft perspectives + your own preview route | Naturally per-branch, because content is a branch |
| Collaboration | Real-time, multiple editors in one document | Git-shaped — one editor per branch, merged by PR |
| Offline / local dev | Needs the hosted API | Files are right there; works offline |
| Content reuse across sites | Strong — one store, many front ends | Weak — content is coupled to one repository |
| Failure mode | Vendor API is a runtime dependency | Merge conflicts, and non-technical editors meeting git |
Nothing in that table makes one of them wrong. They are optimised for different org charts.
Pick Sanity when content outgrows the repo
Sanity's real advantage is that content is a first-class product with its own schema, its own API and its own lifecycle. If the same product descriptions feed a website, a mobile app and a partner feed, storing them as files in one website's repository is already the wrong shape. GROQ is genuinely good at the queries that arrangement produces — joining a document to its references and projecting exactly the fields a view needs, in one request.
Real-time collaboration is the other one that is hard to replicate. Two editors in the same document, seeing each other's cursors, is a platform feature, not a plugin. If your content process involves an editor and a reviewer working simultaneously against a deadline, Git-based tooling will feel like a downgrade no matter how good the visual editing is.
The cost is a runtime dependency. Your build, and depending on your rendering strategy your requests, now depend on an API you do not run.
Pick Tina when content is prose next to the code
Tina — the successor to Forestry — keeps content as files and puts an editor on top. That means content changes are commits: reviewable in a pull request, revertable with git revert, previewable per branch because a branch is a content version. For a documentation site or an engineering blog, that is not a compromise, it is the correct model. The team already has review, history and environments; Tina reuses all three instead of building parallel versions inside a CMS.
The inline editing is the part people underrate. Clicking a heading on the rendered page and typing is a materially lower barrier than finding the right document in a sidebar tree. Tina's own positioning leans on this, and it is fair.
The cost is that git is now in a non-developer's path. Merge conflicts are a real support burden, and "the marketing hire needs to understand branches" is a sentence some teams cannot afford.
The third option: no CMS at all
There is a case where both are the wrong answer, and it is more common than either vendor's comparison page admits: content that only one team writes, only one site renders, and that never changes without a deploy. This storefront's blog is that case, and the numbers are worth stating because they are the scale at which people assume a CMS became mandatory.
As of this post there are 269 published articles and about 470,000 words of MDX in src/content/blog/, with no CMS of any kind. The whole system is three files that must agree:
src/data/blog.ts— a typed metadata registry, currently 5,323 lines.src/content/blog/<slug>.mdx— the prose, compiled at build time.src/lib/blog.ts— an explicitslug → import()map.
The registry entry is an ordinary TypeScript object:
export interface BlogPostMeta {
/** Stable URL segment — `/blog/<slug>`. Never rename a published slug. */
slug: string;
title: string;
/** Meta description + card copy. Keep under ~155 chars. */
description: string;
/** ISO yyyy-mm-dd. Drives sort order, `datePublished`, and the RSS feed. */
date: string;
cluster: BlogCluster;
readingMinutes: number;
/** Catalog slugs this post funnels to — rendered as "Templates in this post". */
relatedTemplates: string[];
relatedSpokes: string[];
tags: string[];
}
Metadata is deliberately separate from prose so the index grid, the home teaser, sitemap.ts and the RSS feed can list posts without compiling a single article. That separation is what a headless CMS sells you, done with an interface.
The loader map is written out slug by slug on purpose, and the comment in the file explains why:
/**
* A dynamic `import(`../content/blog/${slug}.mdx`)` would work, but it hands
* the bundler a wildcard: every `.mdx` under that directory gets pulled into
* the graph, a typo resolves to a runtime error instead of a build error, and
* nothing type-checks.
*/
const postLoaders: Record<string, () => Promise<{ default: ComponentType }>> = {
"supabase-zapier": () => import("@/content/blog/supabase-zapier.mdx"),
"react-passing-props": () => import("@/content/blog/react-passing-props.mdx"),
// …267 more
};
Three files agreeing by hand would be fragile, so npm test enforces it. src/data/__tests__/blog.test.ts fails the build if a post has no loader, if a loader has no post, if a description falls outside 50–200 characters, or — the one that matters most for SEO — if a relatedTemplates slug does not resolve to a real catalog product:
it("relatedTemplates all reference real catalog products", () => {
for (const post of blogPosts) {
for (const slug of post.relatedTemplates) {
expect(getProduct(slug), `${post.slug} → ${slug}`).toBeDefined();
}
}
});
A retired product therefore cannot leave a dead link in a published article. That is referential integrity — the thing a hosted CMS gives you via document references — obtained from a type and a test.
What this trade actually costs
It is not free, and the honest list is short:
- Publishing requires a deploy. A typo fix is a commit, not a save button. For a blog on a CI pipeline that is minutes; for a newsroom it is unacceptable.
- Only developers can publish. There is no editor UI at all. Tina exists precisely to remove this constraint while keeping the files.
- Turbopack has opinions. MDX plugins in
next.config.tsmust be named as strings, not imported functions — Turbopack runs the pipeline in Rust and silently drops a function reference, soremark-gfmconfigured the obvious way produces no tables and no error. That cost real debugging time here. - Article styling lives in one file.
src/mdx-components.tsxis the entire article stylesheet, which is tidy until someone wants per-post layout.
In exchange: zero runtime content requests, zero vendor API in the critical path, content that diffs, and a build that fails loudly when the data is wrong.
Troubleshooting
| Symptom | Cause | Fix |
|---|---|---|
| Non-technical editors blocked by merge conflicts | Git-based CMS with several editors on one branch | Enforce one-branch-per-edit, or move to a hosted store |
| Preview needs a whole parallel environment | Hosted content decoupled from code deploys | Use draft perspectives and a dedicated preview route |
| Content model keeps changing shape | Schema lives in files a deploy has to ship | Prefer a CMS whose schema can migrate independently |
| A link to a deleted item ships to production | No referential integrity between content and catalog | Assert link targets in tests, or use real document references |
| MDX tables render as plain text | Turbopack dropped an imported plugin function | Name the plugin as a string in next.config.ts |
| Build times grow with every article | Every post compiled to list posts | Split metadata from prose so listings never compile MDX |
| Second site needs the same content | Content is coupled to one repository | A hosted store is the right answer; files are not |
Frequently asked questions
Is TinaCMS a good alternative to Sanity? For a single site whose content is prose and whose team already works in pull requests, yes — it offers visual editing without giving up git history or per-branch preview. For content shared across several front ends, or edited simultaneously by multiple people, Sanity's hosted store is the better fit and Tina will feel constrained.
Does Tina store content in git? Yes, that is its defining property. Content is Markdown/MDX/JSON in your repository, so every edit is a commit with an author and a diff, and branches give you content environments for free. Sanity stores content in its own hosted Content Lake and exposes it over an API.
Can I use MDX with both? Tina treats MDX as a first-class content format, since files are the storage layer. Sanity stores structured documents and Portable Text rather than MDX, so you either render Portable Text or serialise to Markdown — workable, but you are converting between two models.
When is neither worth it? When one team writes the content, one site renders it, and publishing on a deploy is fine. At that point a typed registry plus build-time MDX gives you listing, RSS, sitemap entries and link validation with no vendor and no runtime fetch — 269 articles in, this site has not needed more.
Where to take this next
For the wider version of this argument, MDX vs a headless CMS for a blog works through the build-time pipeline in detail. The neighbouring comparisons are Sanity vs Payload CMS, where the axis is hosted versus self-hosted rather than hosted versus git, and Sanity vs headless WordPress.
Templates in this post
ASoc Surge, ASoc Synth and ASoc Tempo ship their copy as typed data files — the same pattern described above, so you can wire either CMS in later without unpicking the markup.
Browse the full sets: Next.js landing page templates, Tailwind landing page templates.
