Skip to content

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 more
  • 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/media and 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.

Database
NeonSupabase
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() from src/payload/local.ts and then payload.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-api exists 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: false together with the user you 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: 0 unless a relationship is displayed; each level of depth is another join and another payload of fields nobody reads. depth: 1 resolves an upload or an author; depth: 2 is almost always a mistake.
  • Use select to 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 limit and a sort. Unbounded find calls are fine on the ten rows you have today.
  • payload.count exists; do not fetch rows to count them.
Route boundaries
  • (payload) is Payload's route group and owns /cms and /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 PostBody in src/components/cms/rich-text.tsx, which styles the generated HTML with design tokens only. No raw colours, and no dangerouslySetInnerHTML with 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
  • push is disabled in payload.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:migrate
    

    and both the new file in src/payload/migrations/ and the updated index.ts go 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 COLUMN by hand when that is what you meant.

  • Adding a required field 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:types
    
  • Adding a custom admin component means regenerating the import map too:

    bun run payload:importmap
    

    It writes src/app/(payload)/cms/importMap.ts: beside the admin route, because routes.admin is /cms, and as TypeScript, because admin.importMap.importMapFile pins 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 in src/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. roles on Users is 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 false when the answer is "some rows". isPublishedOrEditor returns { _status: { equals: "published" } } so anonymous readers get published rows rather than a 403 on the whole collection.
  • read: () => true is only ever correct for content that is meant to be public. Write it explicitly with isPublic so 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
  • slug fields are unique, indexed, and normalised in a beforeValidate hook. 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. publishedAt and slug are indexed here because every query uses them.
  • Uploads declare imageSizes so the site never serves a full-resolution original, and alt is required at 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

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.

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.