Storyblok vs Tina CMS: Who Holds the Content, and Who Pays for Links
Both ship visual editing, so the feature grid hides the real trade. Git-backed means you build the content graph yourself — measured, with the defect that proved it.
Storyblok is a hosted headless CMS: content lives in their Content Lake, editors work in a visual editor against a live preview, and you fetch it over an API. Tina is git-backed: content stays as Markdown or JSON in your repository, and the visual editor commits to a branch. Pick Storyblok when editors outnumber developers; pick Tina when content is prose that belongs beside the code.
Both ship visual editing, so the feature grid makes them look closer than they are. The real question is who holds the content — and the answer changes what you have to build yourself.
The comparison that matters
| Storyblok | Tina CMS | |
|---|---|---|
| Content lives in | Storyblok's Content Lake | Your git repository |
| Format | JSON, served over an API | Markdown/MDX or JSON files |
| Editing | Visual editor over a live preview | Visual editor over a live preview, commits to git |
| Publishing | Immediate, via the API | A commit — then your CI builds |
| Versioning | Their revision history | Your git history, same as code |
| Non-technical editors | Strong, mature | Workable; a CMS-hosted branch flow makes it safer |
| Roles and permissions | Granular, per space | Whatever your git host and Tina Cloud give you |
| Localisation | First-class, per-field | Convention you design (folders or fields) |
| Offline / local dev | Needs the API | Files are right there |
| Cost shape | Seats and plan tiers | Free self-hosted; Tina Cloud for hosted auth |
| Vendor risk | Export via API | None — the files are already yours |
| Relationships between items | Reference fields, queryable | You build the graph yourself |
That last row is the one nobody puts in a comparison, and it is where a git-backed CMS quietly bills you.
Visual editing works the same way in both
Both render your actual site in an iframe and let editors click a region to edit it. The wiring differs:
// Storyblok — the bridge maps a rendered block back to its story field
import { storyblokEditable } from "@storyblok/react";
export function Feature({ blok }) {
return (
<div {...storyblokEditable(blok)}>
<h3>{blok.headline}</h3>
<p>{blok.body}</p>
</div>
);
}
// Tina — the same idea, but the source of truth is a file in your repo
import { useTina } from "tinacms/dist/react";
export default function Page(props) {
const { data } = useTina(props);
return <article data-tina-field={data.post.title}>{data.post.body}</article>;
}
If you evaluate them by clicking around a demo, they will feel equivalent. They are equivalent, for that. The divergence starts the moment there is more than one piece of content and they have to point at each other.
The bill a git-backed CMS sends later
A hosted CMS gives you a reference field. You pick related items from a dropdown, the API returns them, and the relationship is somebody else's data model to maintain. A git-backed CMS gives you files, which means the graph between them is yours to write — and yours to get wrong.
This site is git-backed in the strictest sense: 277 posts as MDX files, a typed registry in src/data/blog.ts, no CMS at all. Here is the defect that cost us, stated with the numbers.
Every post closes with a "Keep reading" rail of three related posts. The original rule was the obvious one — show the newest posts in the same cluster. It is what a reference field would have made unnecessary, and it is what a hosted CMS's "related content" widget would have let a human curate. Left to run, it produced this:
- 12 of 178 posts received every rail link on the site.
- Three of those twelve collected 111 inbound rail links each.
- 166 posts had zero.
Google Search Console in September 2026 reported 35 posts sitting uncrawled, and the overlap with that zero-inbound set was not a coincidence. A page nothing links to is a page the crawler has little reason to schedule.
The fix was to stop asking "which posts are newest" and start asking "which posts have not had a turn" — a wrapping sliding window over the cluster, walked in slug order:
// src/lib/blog.ts — every post gets a turn, and gives exactly three
function ring(posts: BlogPostMeta[], slug: string, limit: number): BlogPostMeta[] {
const pool = [...posts].sort((a, b) => a.slug.localeCompare(b.slug));
const start = pool.findIndex((p) => p.slug === slug);
const others = start === -1 ? pool.length : pool.length - 1;
if (others <= 0) return [];
return Array.from(
{ length: Math.min(limit, others) },
(_, i) => pool[(start + 1 + i) % pool.length],
).filter((p) => p.slug !== slug);
}
Slug order rather than date order is deliberate, and we measured why. In date order a new post's inbound links come almost entirely from the batch published beside it — pages Google has not crawled either, so the links are worth little: only 20% of a new post's inbound rail links came from outside the uncrawled set, against 85% when the window walks slug order.
And because a rule you cannot see is a rule that regresses, it is now an assertion:
// src/data/__tests__/blog.test.ts
it("spreads inbound rail links evenly instead of concentrating them", () => { /* ... */ });
npm test fails if any post drops to zero inbound rail links or if one collects more than six. That test is the reference field, rebuilt by hand, after the outage it should have prevented.
None of this is an argument against Tina. It is the actual scope of "content lives in your repo": you own the relationships, the ordering, the link equity, and the tests that keep them honest. Budget for it, or take Storyblok's reference fields and spend the time elsewhere.
Pick Storyblok when editors outnumber developers
Storyblok's case is operational. Granular roles, a real approval workflow, per-field localisation, an asset CDN and revision history that a marketing lead can use without a git client. Publishing is immediate — no CI, no build, no waiting for a pipeline that someone broke this morning.
You pay in seats, in a runtime dependency your pages now carry, and in the fact that your content sits in an account. Export is possible and the API is good, but "possible" is doing work in that sentence.
Pick Tina when content is prose next to the code
Tina's case is that your content is already in the repository and you want to stop being the person who pastes other people's copy into it. The editor writes through a UI; the result is a commit; review, history, rollback and branch previews are the ones you already have.
Its limits are the ones above: you build the content graph, localisation is a convention you invent, and a contributor who cannot be given repository access needs Tina Cloud's hosted flow. For a team of two to ten technical people, none of that bites. For thirty editors in four languages, all of it does.
Sanity vs TinaCMS is this same trade with a different hosted backend, and MDX vs a headless CMS is the version where you skip the editor entirely.
Mistakes and how they show up
| Symptom | Cause | Fix |
|---|---|---|
| A handful of posts get all the internal links | The "related" rule sorts by date or popularity | Walk a wrapping window over a stable order; assert nobody hits zero |
| New posts stay uncrawled for weeks | Their only inbound links are from equally new pages | Link them from older, already-indexed posts in body prose |
Tina edits land on main unreviewed | The editorial branch was never configured | Point Tina at a branch and merge through a pull request |
| Storyblok preview shows stale content | The bridge is loaded but the draft version isn't requested | Fetch version: "draft" inside the preview, published elsewhere |
| Storyblok bill grows faster than traffic | Uncached API calls on dynamically rendered routes | Prerender, and revalidate from a publish webhook |
| Editors cannot find where a field is defined | Git-backed schema lives in code they don't read | Keep the schema in one file and name the fields the way editors say them |
| Content changes don't appear after publishing | Static pages were built before the change | Webhook → revalidation route, for either CMS |
Frequently asked questions
Does Tina work with the Next.js App Router? Yes. The content is files, so a Server Component reads and renders them directly; the editing layer is a client component mounted only on the routes that need it. The thing to check is that your preview route can read the draft branch.
Is Storyblok's visual editor better than Tina's? More mature, and more forgiving for people who do not think in components — nested blocks, drag-and-drop, and a preview that survives odd layouts. Tina's is good and closer to the code. If your editors are not technical, this gap is larger than a demo suggests.
Can I move off Tina later? Easily — the content is already Markdown in your repository, which is the best migration position available. Moving off Storyblok means an API export and a transform into whatever shape the next system wants. Neither is a crisis; only one is a weekend.
Which is cheaper? Tina, self-hosted, is free and the content costs nothing to store. Storyblok charges per seat and plan. The honest comparison adds the engineering you will do in Tina to build what Storyblok ships — the related-content graph above is a real example, and it took a defect, a rewrite and a test.
Templates in this post
ASoc Ignite — an AI-app marketing site that ships a blog — ASoc Iris, a computer-vision studio site with case studies, and ASoc Keystone, a mortgage-lender site, are typed Next.js and Tailwind source, so the article and listing components are ready for whichever editor you put in front of them.
Browse the full sets: Next.js landing page templates, Tailwind landing page templates.
