Payload CMS Inside Your Next.js App Router — Zero Vendor Lock-In

If you've ever stopped mid-signup on a headless CMS platform and thought "wait, do I actually need this?", this post is for you. Payload CMS installs directly into your Next.js /app directory, lets you query your database straight from Server Components, and ships zero proprietary API dependencies. No REST round-trips, no GraphQL schema to maintain separately, no monthly bill to a third party.

overall flow diagram

This is a practical walkthrough of how the integration works, what the file structure looks like, and what to watch before you run it in production.


1. Why This Matters Now

The default assumption for adding content management to a Next.js app has been: pick a SaaS headless CMS (Contentful, Sanity, Storyblok), wire up their SDK, and live with their pricing tiers. For hobby projects or early-stage products, that's often fine. But for a solo developer or small team building a full-stack Next.js app, you're introducing three things you don't need: a vendor dependency, an external API hop on every request, and a data model you don't fully own.

The real pain is the moment your free tier fills up, or you need a content type the SaaS charges extra for, or you want to run a local dev environment without mocking an external API. That's when people go looking for alternatives.

Payload 3.x changed the calculus. It's a TypeScript-first, open-source headless CMS that installs as part of your Next.js project — not alongside it. The admin UI runs as a route in your own app. Your collections are defined in code. And when you're in a Server Component, you call getPayload() and query directly — no HTTP, no API key, no network hop.

problem or failure flow


2. The Core Idea

Payload CMS lives inside your app/ directory and shares the same Node.js process as your Next.js server — so a Server Component can call the database with a plain function call, not an HTTP request.

The closest analogy: instead of calling an external weather API from your server, imagine the weather database is mounted on the same machine and you just import a function that reads it. That's what Payload's local API gives you.

Here's the comparison against the typical SaaS CMS setup:

Dimension SaaS Headless CMS Payload (in-app)
Data ownership Vendor's cloud Your database
Server Component query HTTP fetch + API key Direct function call
Admin UI Vendor's hosted URL /admin route in your app
Schema definition Vendor dashboard TypeScript config file
Pricing Usage-based tiers Open source (self-hosted)
Local dev Mock or live API Full local DB, no mocking

The tradeoff is real: you're now responsible for running a database (Postgres or MongoDB), handling backups, and deploying the admin UI yourself. For a SaaS product, that's a reasonable trade. For a weekend project, factor that in.

core idea flow


3. How to Implement It

Start from a new or existing Next.js 15 project. Payload's CLI handles the scaffolding.

npx create-next-app@latest my-app --typescript --app
cd my-app
npx create-payload-app@latest

The second command asks for a database (pick Postgres for production use) and installs Payload into your existing project. After it runs, your structure looks like this:

my-app/
├── app/
│   ├── (payload)/
│   │   ├── admin/
│   │   │   └── [[...segments]]/
│   │   │       └── page.tsx      ← Payload admin UI
│   │   └── api/
│   │       └── [...slug]/
│   │           └── route.ts      ← Payload REST API (optional)
│   └── (frontend)/
│       └── page.tsx              ← Your app pages
├── payload.config.ts             ← All CMS config lives here
└── payload-types.ts              ← Auto-generated TypeScript types

Define a collection in payload.config.ts:

import { buildConfig } from 'payload'
import { postgresAdapter } from '@payloadcms/db-postgres'

export default buildConfig({
  db: postgresAdapter({
    pool: { connectionString: process.env.DATABASE_URI },
  }),
  collections: [
    {
      slug: 'posts',
      fields: [
        { name: 'title', type: 'text', required: true },
        { name: 'content', type: 'richText' },
        { name: 'publishedAt', type: 'date' },
      ],
    },
  ],
  secret: process.env.PAYLOAD_SECRET!,
})

Now query it from a Server Component — no fetch, no API key:

// app/(frontend)/blog/page.tsx
import { getPayload } from 'payload'
import configPromise from '@payload-config'

export default async function BlogPage() {
  const payload = await getPayload({ config: configPromise })

  const { docs } = await payload.find({
    collection: 'posts',
    where: { publishedAt: { less_than_equal: new Date().toISOString() } },
    sort: '-publishedAt',
    limit: 10,
  })

  return (
    <ul>
      {docs.map((post) => (
        <li key={post.id}>{post.title}</li>
      ))}
    </ul>
  )
}

Verify the setup locally:

# Start Postgres (Docker example)
docker run --name payload-db -e POSTGRES_PASSWORD=secret -p 5432:5432 -d postgres

# Set env vars
export DATABASE_URI="postgresql://postgres:secret@localhost:5432/payload"
export PAYLOAD_SECRET="your-secret-key-min-32-chars"

# Run the dev server
npm run dev

Expected output on first run:

▲ Next.js 15.x
- Local: http://localhost:3000
✓ Payload: Connected to database
✓ Admin UI: http://localhost:3000/admin

Hit http://localhost:3000/admin to create your first user and start adding content.

execution or verification flow


4. What to Watch in Production

Database management is now your job. Payload doesn't host anything — you need a Postgres (or MongoDB) instance with backups, connection pooling, and a migration strategy. For Postgres on a managed host, PlanetScale (MySQL-compatible, not directly), Neon, or Supabase all work. Run payload migrate as part of your deployment pipeline, not after the fact.

# Add to your CI/CD deploy step before app start
npx payload migrate

The admin route is public by default during development but requires auth in production. Make sure PAYLOAD_SECRET is a cryptographically strong value (32+ random chars), and never commit it to version control.

# Generate a good secret
node -e "console.log(require('crypto').randomBytes(32).toString('hex'))"

Cold starts on serverless deployments (Vercel, etc.) will hit the database connection limit fast. Payload's getPayload() initializes a connection pool on first call. Use a connection pooler like PgBouncer or Neon's built-in pooling, and set pool.max conservatively in your config:

postgresAdapter({
  pool: {
    connectionString: process.env.DATABASE_URI,
    max: 5, // tune based on your plan's connection limit
  },
})

Rich text (Lexical editor) output is not plain HTML. By default, Payload stores rich text as a Lexical JSON blob. You'll need @payloadcms/richtext-lexical's serializer to convert it to JSX or HTML before rendering. Plan for this before you launch — retrofitting the serializer later is annoying.

import { RichText } from '@payloadcms/richtext-lexical/react'

// In your Server Component
<RichText data={post.content} />

Environment differences: Payload's admin UI works best when the Node.js process has write access to temporary file storage (for media uploads). On Docker, mount a volume. On Vercel, use an S3-compatible adapter for media — local disk uploads won't persist across deployments.


Closing

If you're building a full-stack Next.js app and you keep reaching for a SaaS CMS out of habit, Payload 3.x is worth a weekend spike. You get typed collections, a built-in admin UI at /admin, and direct database queries from Server Components — with no external API dependency to worry about.

Next step: explore Payload's access control functions to lock down collection reads per user role, and look at hooks (beforeChange, afterRead) for the kind of data processing logic you'd normally push into a separate API layer.


🐦 Faster updates on X: @baegseungh7061
📚 More in this series: All posts
💌 Subscribe: Follow on X or grab the RSS

댓글