Blog
Next.js boilerplate with Payload blog
A full CMS in your own repo and database. No second service, no content API bill.
Payload CMS running inside this Next.js app, with the admin panel at /cms. Content lives in the Postgres database you already chose. Blog pages read through the local API, not over HTTP. Drafts autosave, previews are authenticated and SQL migrations are committed.
What Payload blog adds to the agent layer: 2 rules · 2 skills · 5 solution docs
Maintained by @raviMITNext.js on Vercel
From the manifest
Should you pick Payload blog?
Pick it if
Teams who want editors in an admin UI but will not put their content in someone else's database. Strong when CMS content joins application data. Also when you need custom fields, hooks or access rules a hosted CMS will not give you. Or when data residency and export matter.
Watch out for
- You operate it. Migrations, backups, upgrades and the admin bundle's build time are yours. A hosted CMS has none of that, and that is the whole trade.
- It shares your database connection budget. The admin panel and your app draw from the same pool, so on serverless a pooled connection string is not optional.
Show 3 moreShow fewer
- Cold starts are real. The admin routes load Payload, the database driver and the editor bundle, so the first hit after idle is slow. It is an internal tool, so this is usually fine. Tell your editors before they report it as a bug.
- Uploads need an object store before you deploy. Local disk works on your laptop and disappears on serverless. The media collection ships pointed at
public/mediaand must be repointed. - Schema changes are code plus a migration. That is a feature in review and a speed bump when an editor asks for one more field.
What it costs
Free and open source (MIT). You host it, so the cost is the Postgres and Vercel functions you already pay for. Payload also sells an Enterprise plan with dedicated support and SSO. It has no public price.
Prices change. Check with Payload blog before you commit.
registry/tested.yaml
Tested with Payload blog
Each pair was installed, typechecked, linted, built and booted together.
- Admin panel
- Admin panel
- Error tracking
- Sentry
- Customer support
- Crisp
What it adds
What Payload blog adds to the repo
Read straight from the blog-payload manifest, so it is exactly what lands in your repo.
Environment variables
PAYLOAD_SECRETRequired
Signs admin session cookies and encrypts stored credentials. Generate 32+ random characters and keep them stable: rotating it logs every editor out and invalidates anything Payload encrypted with it.
- Where to get it
- Generate one locally with
openssl rand -base64 32. Set a different value in each environment, and never commit it. - Placeholder
- replace-with-32-random-characters
Dependencies
- @payloadcms/db-postgres^3.90.2
- @payloadcms/next^3.90.2
- @payloadcms/richtext-lexical^3.90.2
- payload^3.90.2
- sharp^0.35.4
Scripts
- bun run payload
bunx payload
- bun run payload:importmap
bunx payload generate:importmap
- bun run payload:migrate
bunx payload migrate
- bun run payload:migrate:create
bunx payload migrate:create
- bun run payload:types
bunx payload generate:types
Files it writes
17 files, at these exact paths.
src/16 files
app/9 files
(frontend)/3 files
api/1 file
blog-preview/1 file
- route.ts
blog/2 files
[slug]/1 file
- page.tsx
- page.tsx
(payload)/6 files
cms/3 files
[[...segments]]/2 files
- not-found.tsx
- page.tsx
- importMap.ts
cms-api/2 files
[...slug]/1 file
- route.ts
graphql/1 file
- route.ts
- layout.tsx
components/1 file
cms/1 file
- rich-text.tsx
payload/6 files
collections/3 files
- media.ts
- posts.ts
- users.ts
migrations/1 file
- index.ts
- access.ts
- local.ts
- payload.config.ts
Stack slots it fills
The stack declares these injection points; this battery supplies the fragment, so the provider tree, the env check and the schema stay one file each instead of many.
- @slot bare-route-groups
- @slot env-required
- @slot nav-links
- @slot next-config-wrappers
- @slot verify-checks
The differentiator
What Payload blog teaches your agent
Other starter kits stop at the package. This is the part an agent reads: where it may work, what it must never do there, and the problems someone already solved.
Rules (2)
Loaded when the agent opens a matching file.
Read Payload through the local API, never over HTTP
Loads onsrc/app/(frontend)/**src/app/(payload)/**src/payload/local.tssrc/components/cms/**.claude/rules/payload-data-access.md
The local API is the only way pages read content
- Server components and route handlers call
getPayloadClient()fromsrc/payload/local.tsand thenpayload.find/findByID/count. That is a function call into a Payload instance in this process, which talks straight to Postgres. - Never
fetch("/cms-api/posts")from a server component. It leaves the process, opens a connection to your own deployment, wakes a second function, serialises the whole document to JSON and parses it again: to reach code that was already loaded. It also drops the request's identity, so access rules see an anonymous caller. /cms-apiexists for the admin panel's browser bundle and for genuine external consumers. Those are its only two callers.- Client components never talk to Payload. Fetch in the server component above and pass plain data down as props: the Payload instance holds a database pool and cannot be serialised across that boundary anyway.
The local API bypasses access control by default
This is the single most surprising thing about Payload, and it has bitten every team that uses it:
// returns drafts, unpublished posts and everything else
await payload.find({ collection: "posts" });
overrideAccess defaults to true in the local API, because it is normally
used by trusted server code. On a public page that means your carefully written
read rule does nothing.
So on any page a visitor can reach:
- filter explicitly (
where: { _status: { equals: "published" } }) and treat that filter as security-relevant code, not a convenience; or - pass
overrideAccess: falsetogether with theuseryou resolved from the request, when you want Payload's own rules applied.
Draft content is read only when draftMode() is enabled, which only an
authenticated editor can turn on through /api/blog-preview. A draft page also
sets robots: { index: false } so a shared preview link cannot be indexed.
Ask for what you render
depth: 0unless a relationship is displayed; each level of depth is another join and another payload of fields nobody reads.depth: 1resolves an upload or an author;depth: 2is almost always a mistake.- Use
selectto name the fields a list needs. Rich text is the biggest column on a post and an index page never renders it. - Every list query has a
limitand asort. Unboundedfindcalls are fine on the ten rows you have today. payload.countexists; do not fetch rows to count them.
Route boundaries
(payload)is Payload's route group and owns/cmsand/cms-api, including its own root layout and CSS. Do not move those routes into the site's layout or wrap them in the site's providers.(frontend)is your blog. It uses the app's design tokens, not Payload's admin styles.- Rich text renders through
PostBodyinsrc/components/cms/rich-text.tsx, which styles the generated HTML with design tokens only. No raw colours, and nodangerouslySetInnerHTMLwith content from the editor.
Collections are code, and every change ships with a migration
Loads onpayload.config.tssrc/payload/**.claude/rules/payload-schema.md
A collection change is not done until the migration is committed
pushis disabled inpayload.config.ts. Payload will not quietly alter the database to match the config at boot, in development either. That is deliberate: a schema that drifts on someone's laptop is a schema nobody can reproduce.Every field added, renamed or removed needs:
bun run payload:migrate:create <name> bun run payload:migrateand both the new file in
src/payload/migrations/and the updatedindex.tsgo in the commit.Read the generated SQL before you apply it. A rename is emitted as a drop plus an add unless you edit it, which is a silent data loss on a populated table. Turn it into an
ALTER TABLE ... RENAME COLUMNby hand when that is what you meant.Adding a
requiredfield to a collection that already has rows needs a default or a backfill in the same migration, or the migration fails on production data that passed on your empty laptop database.Never edit an applied migration. Payload records which ones ran; changing one after the fact means two databases with the same migration list and different schemas. Write a new migration.
Regenerate types after a schema change so the rest of the repo type-checks against reality:
bun run payload:typesAdding a custom admin component means regenerating the import map too:
bun run payload:importmapIt writes
src/app/(payload)/cms/importMap.ts: beside the admin route, becauseroutes.adminis/cms, and as TypeScript, becauseadmin.importMap.importMapFilepins the path. Do not let it fall back to a generated.js: the layout and the admin page import that file, and an untyped import is one the compiler cannot check.
Access control is declared, never inherited
- Every collection declares all four operations (
create,read,update,delete) using the helpers insrc/payload/access.ts. Payload's default when a block is missing is "any authenticated user may do it", which is how an editor ends up able to delete accounts. - Sensitive fields carry their own
access.rolesonUsersis the example: the collection lets a user update their own record, so without a field-level rule they could promote themselves to admin. - Prefer returning a query constraint over
falsewhen the answer is "some rows".isPublishedOrEditorreturns{ _status: { equals: "published" } }so anonymous readers get published rows rather than a 403 on the whole collection. read: () => trueis only ever correct for content that is meant to be public. Write it explicitly withisPublicso the choice is visible in review rather than implied by an absent line.- After changing an access rule, test it as the least-privileged user that should still work, not as the admin you are logged in as.
Collection shape
slugfields areunique,indexed, and normalised in abeforeValidatehook. A published slug is a permanent URL: add a redirect rather than renaming it.- Anything with a public page uses drafts (
versions: { drafts: ... }) so the Preview button has something to preview. - Index every field you filter or sort by.
publishedAtandslugare indexed here because every query uses them. - Uploads declare
imageSizesso the site never serves a full-resolution original, andaltisrequiredat the schema level rather than defaulted in a component.
Skills (2)
Invoked by name.
- /add-collection
Add a Payload collection end to end: fields, explicit access control, migration, regenerated types and the page that renders it.
.claude/skills/add-collection/SKILL.md
- /payload-migrate
Create, review, apply and deploy Payload migrations safely, including renames, backfills and what to do when a migration fails halfway.
.claude/skills/payload-migrate/SKILL.md
Solution docs (5)
Written before you hit the problem. Each one ships in the repo at docs/solutions/ and is published here as a cookbook page.
- Payload access control when your app already has authTwo user tables is the right answer. Map your app's roles onto Payload's rules instead of merging the tables, and never leave an access block undeclared.docs/solutions/blog-payload/access-control-matching-your-auth-battery.md
- Payload uploads vanish after a deploy: move media to an object storestaticDir writes to a filesystem that disappears on serverless. Add a storage adapter, keep the database rows, and migrate the files you already have.docs/solutions/blog-payload/media-on-an-object-store.md
- Payload migrations on Vercel without a broken deployVercel runs your build, not your migrations. Run them in the build command, forward-only, and split the schema deploy from the code that needs it.docs/solutions/blog-payload/migrations-on-vercel.md
- Running Payload inside the same Next.js appThe admin panel is a route group, not a second service. Route groups, withPayload, the import map and why your blog pages must not fetch their own API.docs/solutions/blog-payload/payload-inside-the-same-next-app.md
- Letting Payload share your Postgres without wrecking your migrationsTwo migration tools in one database will fight over table names and drop each other's tables. A separate schema keeps them apart for one config line.docs/solutions/blog-payload/sharing-postgres-with-your-app-tables.md
How it fits
What Payload blog needs, and what it goes well with
The resolver enforces this before it generates anything, and names every addition it makes.
Requires
- A database battery. The resolver adds the default one for you and tells you why.
Pairs well with
- A storage battery. Suggested, never added for you.
Cannot be combined with
Compared with the alternatives
Build a repo with Payload blog
Free and MIT. The builder opens with Payload blog picked. You download the zip right away, and we email you the link too.