Skip to main content
ASoc
Comparison

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.

The ASoc Team9 min read

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

SanityTinaCMS
Where content livesHosted content store (Sanity's Content Lake)Markdown/MDX/JSON files in your git repo
Query languageGROQ (and GraphQL)GraphQL over the local/hosted data layer
Schema definitionTypeScript/JavaScript schema filesTypeScript config describing collections and fields
Editing modelSanity Studio — a structured form-based editorVisual, click-the-page inline editing plus forms
VersioningDocument history inside the platformGit history — commits, branches, blame, reverts
PreviewDraft perspectives + your own preview routeNaturally per-branch, because content is a branch
CollaborationReal-time, multiple editors in one documentGit-shaped — one editor per branch, merged by PR
Offline / local devNeeds the hosted APIFiles are right there; works offline
Content reuse across sitesStrong — one store, many front endsWeak — content is coupled to one repository
Failure modeVendor API is a runtime dependencyMerge 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:

  1. src/data/blog.ts — a typed metadata registry, currently 5,323 lines.
  2. src/content/blog/<slug>.mdx — the prose, compiled at build time.
  3. src/lib/blog.ts — an explicit slug → 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.ts must be named as strings, not imported functions — Turbopack runs the pipeline in Rust and silently drops a function reference, so remark-gfm configured the obvious way produces no tables and no error. That cost real debugging time here.
  • Article styling lives in one file. src/mdx-components.tsx is 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

SymptomCauseFix
Non-technical editors blocked by merge conflictsGit-based CMS with several editors on one branchEnforce one-branch-per-edit, or move to a hosted store
Preview needs a whole parallel environmentHosted content decoupled from code deploysUse draft perspectives and a dedicated preview route
Content model keeps changing shapeSchema lives in files a deploy has to shipPrefer a CMS whose schema can migrate independently
A link to a deleted item ships to productionNo referential integrity between content and catalogAssert link targets in tests, or use real document references
MDX tables render as plain textTurbopack dropped an imported plugin functionName the plugin as a string in next.config.ts
Build times grow with every articleEvery post compiled to list postsSplit metadata from prose so listings never compile MDX
Second site needs the same contentContent is coupled to one repositoryA 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.

Keep reading

Comparison7 min read

Sass vs. Tailwind: 133 Lines and One Arbitrary Selector

Sass's four features, checked one at a time against this codebase's real @theme block, group-hover usage, and the single [&_selector] Tailwind still reaches for.

Read more
Comparison8 min read

shadcn vs. Tailwind Is a Category Error (One Runs on the Other)

92 components, 13 runtime dependencies, zero UI libraries — what hand-rolling actually cost this codebase, and the seven components shadcn would have handed over.

Read more