Skip to main content
ASoc
Comparison

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.

The ASoc Team7 min read

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

SanityPayload
HostingManaged — Content Lake, CDN, backupsYours — Node process plus a database
LicenceProprietary platform, open-source StudioMIT, end to end
DatabaseTheirs, opaqueYours — Postgres, MongoDB or SQLite
SchemaTypeScript schema, deployed to StudioTypeScript config, compiled with the app
TypesGenerated from the schemaGenerated, and the local API is typed directly
Reading contentNetwork call (GROQ/GraphQL)Local API — a function call, no HTTP
Admin UISanity Studio, deployed separatelyGenerated, mounted as routes in your app
Real-time co-editingBuilt inNot natively
ImagesHosted CDN, URL-based transformsYour storage, your pipeline
Cost shapeSeats plus API usageInfrastructure only
Who operates it at 3amSanityYou
Air-gapped / on-premNot possibleSupported

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:

FileMedian sizeRole
cover.jpg137 KBSEO surface — og:image, Product schema. Never shrunk
cover-card.webp (1060w)31 KBEvery card on every grid
cover-view.webp (1600w)40 KBThe 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

SymptomCauseFix
Payload admin is slow in productionIt shares a process with the app and lost the resource contestGive the admin its own deployment, or size the instance for both
Content updates don't appear on a static sitePages were prerendered; the CMS changed after the buildRevalidate on a webhook from the CMS, not on a timer
Sanity bill grows faster than trafficUncached queries on a dynamically rendered routeCache the fetch, or prerender the route; a per-request GROQ call is a per-request charge
Payload types are stale after a schema changeGenerated types weren't regeneratedMake type generation part of the build, not a manual step
getPayload called per request in a hot pathA new instance per call instead of the cached configPass the shared config and let Payload reuse the instance
Images balloon page weight after moving off a CDNFull-size uploads served straight to cardsGenerate 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.

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