Sanity vs Payload CMS: Hosted Content Lake or a CMS in Your Repo
Payload installs into your Next.js app, so content reads skip HTTP entirely. Sanity hands the 3am pager to someone else. Measured against a build with no CMS at all.
Choose Sanity when you want the content backend to be somebody else's operational problem, and Payload when you want the CMS to live inside the same repository and the same deploy as the app. Sanity is a hosted Content Lake you query over the network. Payload is an MIT-licensed Node framework that runs on your infrastructure — in a Next.js project, inside the app itself.
That last detail is the one that actually separates them, and it is newer than most comparisons account for. Payload installs into your Next.js app, which means content reads can skip HTTP entirely.
The comparison that matters
| Sanity | Payload | |
|---|---|---|
| Hosting | Managed — Content Lake, CDN, backups | Yours — Node process plus a database |
| Licence | Proprietary platform, open-source Studio | MIT, end to end |
| Database | Theirs, opaque | Yours — Postgres, MongoDB or SQLite |
| Schema | TypeScript schema, deployed to Studio | TypeScript config, compiled with the app |
| Types | Generated from the schema | Generated, and the local API is typed directly |
| Reading content | Network call (GROQ/GraphQL) | Local API — a function call, no HTTP |
| Admin UI | Sanity Studio, deployed separately | Generated, mounted as routes in your app |
| Real-time co-editing | Built in | Not natively |
| Images | Hosted CDN, URL-based transforms | Your storage, your pipeline |
| Cost shape | Seats plus API usage | Infrastructure only |
| Who operates it at 3am | Sanity | You |
| Air-gapped / on-prem | Not possible | Supported |
The rows that decide it are who operates it at 3am and reading content. Everything else is a preference; those two are constraints.
The local API is the real argument for Payload
In a Next.js app, Payload's local API runs in the same process as your Server Components. A page does not fetch its own CMS over the network:
// Payload — no HTTP, no API key, no network hop
import { getPayload } from "payload";
import config from "@payload-config";
export default async function Page({ params }: { params: Promise<{ slug: string }> }) {
const { slug } = await params;
const payload = await getPayload({ config });
const { docs } = await payload.find({
collection: "posts",
where: { slug: { equals: slug } },
limit: 1,
});
return <Article post={docs[0]} />;
}
Compare the Sanity version, which is a network request every time it is not cached:
const post = await sanity.fetch(
`*[_type == "post" && slug.current == $slug][0]{ title, body }`,
{ slug },
);
At build time, for a statically rendered site, this difference mostly disappears — both run once per page and the result is baked into HTML. It matters when you render dynamically, when you have a lot of pages, or when the CMS is briefly unreachable. A local query cannot fail from a network partition, and docs[0] is typed from your own config rather than from whatever the query happened to project.
The cost is symmetric and worth stating plainly: that same co-location means a Payload outage is your outage, your database is your backup problem, and scaling the CMS means scaling the app.
What "no image CDN" costs, measured
Sanity's image pipeline is a genuine headline feature — upload once, request any size through URL parameters. Payload has no equivalent; you bring storage and you resize things yourself. Comparisons usually leave that as a checkbox. Here is what the alternative actually costs, measured on this storefront, which builds its variants at compile time rather than transforming at request time.
src/lib/imageVariants.ts resolves every rendered <img> to a pre-built derivative:
/** Card thumbnails: 530px CSS wide at DPR 2, so 1060 covers every display. */
export const CARD_VARIANT_WIDTH = 1060;
/** Full-bleed 16:9 slides on the detail page, matching the markup's width. */
export const VIEW_VARIANT_WIDTH = 1600;
/** Card-sized cover — product grids, the related rail, dashboard thumbnails. */
export function cardImage(src: string): string {
return variant(src, "card");
}
Across 110 products, the numbers come out like this:
| File | Median size | Role |
|---|---|---|
cover.jpg | 137 KB | SEO surface — og:image, Product schema. Never shrunk |
cover-card.webp (1060w) | 31 KB | Every card on every grid |
cover-view.webp (1600w) | 40 KB | The detail-page frame |
A card therefore ships 31 KB instead of 137 KB — a 77% reduction — and it costs one script run (scripts/images/build-variants.ts), zero request-time compute and zero image-optimization spend. npm test fails if any product is missing a variant a component can ask for.
That is the honest shape of the trade. A build-time pipeline replaces an image CDN perfectly well for a catalog that changes on deploys. It replaces it badly the moment editors upload images between deploys and expect them resized — which is precisely the case Sanity is selling to, and precisely where self-hosting stops being free.
Where neither one belongs
This storefront runs no CMS at all. The catalog is a typed TypeScript array in src/data/catalog.ts — 111 products, 110 of them live — and the blog is 262 MDX files compiled at build time against a typed registry. A full production build emits 395 prerendered pages in 37 seconds, with no content API in the request path.
The test suite is doing the job a CMS's schema validation would:
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();
}
}
});
This works because the people who write the content are the people who can open a pull request. It is a genuine third option, and it has a sharp expiry date: the first non-technical editor who needs to publish without a deploy ends it. Recognising that moment early is worth more than choosing correctly between Sanity and Payload today, because either migration from files is straightforward and a migration between the two is not. MDX vs a headless CMS is the long version of where that line falls, and Sanity vs headless WordPress is the same hosted-versus-self-hosted question asked against an incumbent you may already run.
Mistakes and how they show up
| Symptom | Cause | Fix |
|---|---|---|
| Payload admin is slow in production | It shares a process with the app and lost the resource contest | Give the admin its own deployment, or size the instance for both |
| Content updates don't appear on a static site | Pages were prerendered; the CMS changed after the build | Revalidate on a webhook from the CMS, not on a timer |
| Sanity bill grows faster than traffic | Uncached queries on a dynamically rendered route | Cache the fetch, or prerender the route; a per-request GROQ call is a per-request charge |
| Payload types are stale after a schema change | Generated types weren't regenerated | Make type generation part of the build, not a manual step |
getPayload called per request in a hot path | A new instance per call instead of the cached config | Pass the shared config and let Payload reuse the instance |
| Images balloon page weight after moving off a CDN | Full-size uploads served straight to cards | Generate size variants at build time and point components at those, never the original |
Frequently asked questions
Is Payload actually free? The software is — MIT, no seat pricing, no usage tier. The database, the hosting, the backups, the monitoring and the person who restores it are not. For a team that already runs Node services, that marginal cost is close to zero and Payload is very hard to beat. For a team that runs no infrastructure, "free" means "you now run infrastructure".
Can Payload replace Sanity for a large editorial team? For structure and permissions, yes. The thing to check first is concurrent editing: Sanity has real-time collaboration built in, Payload does not, and for a team of ten people working the same pages that difference is felt daily. For a handful of editors touching different documents, it never comes up.
Which is better with the Next.js App Router?
Payload integrates more tightly — it installs into the app, mounts its admin as routes, and its local API is a function call from a Server Component. Sanity works perfectly well through ordinary fetch, which is also its advantage: nothing is coupled to your framework, and the same content feeds a mobile app or a second site without a second deployment.
Do I need to decide before building the marketing site? No, and deciding first often wastes work. Both hand content to an async Server Component, so build the pages against typed local data and swap the data source once the editorial workflow is settled. What you would throw away is a fetch layer, not a front end.
Templates in this post
ASoc Reach, ASoc Realm — which ships a journal section — and ASoc Relay are all typed Next.js and Tailwind source, so the article and listing components are ready for whichever content source you attach.
Browse the full sets: Next.js landing page templates, Tailwind landing page templates.
