Strapi vs Payload CMS: Where the Content Model Lives
Strapi's GUI writes your schema; Payload's schema is TypeScript you review. That one difference decides the rest — measured against 391 tests and no CMS at all.
Strapi and Payload are both open-source, self-hosted Node CMSs, and they disagree about one thing: where the content model lives. Strapi's Content-Type Builder is a GUI that writes schema files. Payload's collections are TypeScript you author directly, compiled with your app. Pick Payload if the model belongs in code review; pick Strapi if non-developers need to change it.
Everything else — REST versus GraphQL, the admin theme, the plugin count — follows from that one decision, and most comparisons spend their word count on the parts that follow rather than the part that decides.
The comparison that matters
| Strapi | Payload | |
|---|---|---|
| Content model authored in | Content-Type Builder GUI (or JSON by hand) | TypeScript config files |
| Model in version control | Yes, as generated schema.json | Yes, as the source you wrote |
| Who can change the model | Anyone with admin access | Anyone who can open a pull request |
| Relationship to your app | A separate service you deploy | Installs into your Next.js app |
| Reading content | HTTP — REST or GraphQL | Local API: a function call, no network |
| Database | SQLite, Postgres or MySQL | Postgres, MongoDB or SQLite |
| Type safety | Generated types, a step behind the GUI | The config is the type |
| Admin UI | Ships its own React admin | Generated, mounted as routes in your app |
| Plugin discovery | Public marketplace, browsable in-admin | Documented packages, installed by hand |
| Licence | MIT (Enterprise tier for SSO/audit) | MIT |
| Framework coupling | None — any front end | Strong, and deliberate, to Next.js |
Who can change the model is the row to read twice. It is not a capability difference; it is a governance difference, and it has consequences the feature matrix cannot show.
What "schema in the GUI" costs, concretely
Strapi's builder is genuinely pleasant, and it writes files:
// src/api/post/content-types/post/schema.json — generated by the GUI
{
"kind": "collectionType",
"collectionName": "posts",
"info": { "singularName": "post", "pluralName": "posts" },
"attributes": {
"title": { "type": "string", "required": true },
"slug": { "type": "uid", "targetField": "title" },
"body": { "type": "richtext" }
}
}
So the model is in git. The catch is that a schema change and the migration it implies arrive from two directions: the file lands in your repository from someone clicking in an admin panel, usually on a running environment. Reviewing it after the fact is possible; preventing a bad one is not. Teams that care about this end up disabling the builder in production, which removes the feature that made Strapi the easier choice.
Payload never has the problem because there is no other place to author it:
// payload.config.ts — the model, and its type, are the same artifact
import { buildConfig } from "payload";
export default buildConfig({
collections: [
{
slug: "posts",
admin: { useAsTitle: "title" },
fields: [
{ name: "title", type: "text", required: true },
{ name: "slug", type: "text", unique: true, index: true },
{ name: "body", type: "richText" },
{ name: "related", type: "relationship", relationTo: "templates", hasMany: true },
],
},
],
});
The price is symmetric and worth saying plainly: every content-model change is now a deploy, and a marketing team that wanted to add one field waits for one.
The local API is Payload's other argument
Payload runs inside the Next.js app. A Server Component reads content without HTTP:
import { getPayload } from "payload";
import config from "@payload-config";
export default async function Post({ 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]} />;
}
Strapi is always a network hop:
const res = await fetch(`${process.env.STRAPI_URL}/api/posts?filters[slug][$eq]=${slug}&populate=*`, {
headers: { Authorization: `Bearer ${process.env.STRAPI_TOKEN}` },
});
For a statically rendered site the difference mostly evaporates — both run once per page at build time and the output is HTML either way. It matters when you render dynamically, when there are thousands of pages, or when the CMS is briefly unreachable. And the coupling cuts both ways: a Payload outage is your app's outage, while a Strapi outage during a build is a failed build you can retry.
That framework coupling is also the row that disqualifies Payload outright for some teams. If the same content has to feed a Next.js site, a mobile app and a partner's integration, Strapi's framework-agnostic API is the point, not a consolation.
Schema in code, without either of them
This storefront runs the content model Payload advocates for, without a CMS, which makes it a useful measurement of what the pattern buys you on its own.
The catalog of 111 products and the registry of 277 blog posts are TypeScript in src/data/. Because the model is code, the invariants can be code too — and that is the part a GUI schema cannot express at all. Both CMSs can enforce "this field is required"; neither can enforce the rules that actually break this site:
// src/data/__tests__/blog.test.ts
it("relatedTemplates all reference real catalog products", () => { /* ... */ });
it("every post has a registered MDX loader", () => { /* ... */ });
it("has no loader without a matching post entry", () => { /* ... */ });
it("spreads inbound rail links evenly instead of concentrating them", () => { /* ... */ });
// src/data/__tests__/internalLinking.test.ts
it("every available product is listed on at least one category hub", () => { /* ... */ });
it("dates product pages from real dates, never the build clock", () => { /* ... */ });
it("EVERY date in the sitemap is a declared day, not a clock reading", () => { /* ... */ });
391 assertions across 31 files, 3.3 seconds. Retiring a product cannot leave a dead link in a published post, because the assertion that a relatedTemplates slug resolves to a real product runs before the build does. A referential-integrity constraint in Strapi or a relationship field in Payload gets you part of that — the part where the row exists. Neither gets you "every available product must appear on at least one category hub", which is an editorial rule, not a data rule, and it is the one that was silently violated for 24 products until a test was written for it.
The trade is the honest one: nobody without repository access publishes anything. Sanity vs Payload CMS is this question with a hosted backend on the other side, and Strapi vs headless WordPress is it against an incumbent you may already be running.
Choosing, in one pass
Pick Payload when your team lives in TypeScript and Next.js, the content model should move through pull requests, and a schema change deploying with the app is a feature. Pick Strapi when non-developers need to add fields, when more than one client consumes the content, or when the CMS should outlive a front-end rewrite.
If both fit, pick on the second-order cost: Payload makes you couple, Strapi makes you operate a second service.
Mistakes and how they show up
| Symptom | Cause | Fix |
|---|---|---|
| Strapi types drift from the admin | Someone changed a content type through the GUI on a live environment | Generate types in CI and fail the build on a diff; restrict the builder in production |
| Payload admin is slow under load | It shares a process with the app and lost the resource contest | Give the admin its own deployment, or size for both |
getPayload called per request in a hot path | A fresh instance per call instead of the cached config | Pass the shared config; let Payload reuse the instance |
| Strapi returns IDs instead of related content | populate defaults to shallow | Ask for the whole page in one query; do not fan out per relation |
| Content changes don't show on a static site | Pages were prerendered before the change | Revalidate from a CMS webhook, not on a timer |
| A deleted item leaves dead links | Referential integrity covered the row, not the page | Assert the editorial rule in a test that runs before the build |
| Payload migration fails on deploy | Schema changed but migrations weren't generated | Make payload migrate:create part of the change, and run migrations in the release step |
Frequently asked questions
Is Payload only for Next.js? The admin and the local API are built around Next.js, and that is where it is worth choosing. It exposes REST and GraphQL too, so other clients can read it — but if your primary consumer is not Next.js, most of what you are paying the coupling for goes unused.
Can non-developers use Payload? For content, yes — the generated admin is good. For the model, no, and that is the design. If marketing needs to add a field without engineering, that is Strapi's row.
Which has the better plugin ecosystem? Strapi, by discovery: a public marketplace browsable from inside the admin. Payload's extensions are packages you find in the docs. Weigh it by what you actually need — for most projects it is auth, storage and a rich-text editor, which both cover.
Do I need either for a marketing site with a blog? Not until someone who cannot open a pull request needs to publish. Typed content files give you review, history, and a build that cannot fail from another service's outage. Write the trigger down in advance, because the migration out of files is cheap and the migration between two CMSs is not.
Templates in this post
ASoc Cover — an insurance site with instant quotes — ASoc Echo, an AI chatbot landing page with a live widget preview, and ASoc Edge, an applied-AI agency site, all ship typed Next.js and Tailwind source, so the content components are ready for a local API, a REST client, or plain typed files.
Browse the full sets: Next.js landing page templates, Tailwind landing page templates.
