Site icon Ampersand Tutorials

Payload CMS Tutorial: Install Payload 3 and First Collection

Quick answer: Scaffold with npx create-payload-app, choose Postgres, MongoDB or SQLite, then define your content in code: a collection is an object with a slug, an access block and a fields array, and Payload generates the database schema, the admin UI at /admin and the TypeScript types from it. Read content back with the Local API (getPayload plus payload.find) inside a React Server Component. You need Node.js 20.18.1+ and a supported Next.js version.

Part 2 of our Payload CMS series. New to the framework? Read what Payload CMS is first.

1. Check your versions before anything else

Payload is strict about its peer versions, and a mismatch produces confusing admin-panel errors rather than a clear failure.

RequirementSupported
Node.js20.18.1 or newer
Next.js15.2.9–15.2.x, 15.3.9–15.3.x, 15.4.11–15.4.x, or 16.2.6+
Package managerpnpm (preferred), npm, or yarn 2+ — yarn 1.x is not supported
DatabasePostgres, MongoDB or SQLite
node --version      # v20.18.1 or newer
pnpm --version      # optional but recommended

2. Scaffold the project

npx create-payload-app@latest my-cms
cd my-cms

The prompts ask for a project name, a database adapter, and whether to include a template such as the website starter. Choose Postgres for a relational schema you can query with SQL later, MongoDB if your documents are deeply nested, or SQLite for a local prototype that needs zero infrastructure. Then start it:

pnpm dev

Open http://localhost:3000/admin. On a fresh database Payload asks you to create the first admin user — that is your login, so do it before sharing the URL.

Adding Payload to an existing Next.js app

You can skip the scaffolder. Install the packages, then wire the three files:

pnpm i payload @payloadcms/next @payloadcms/richtext-lexical @payloadcms/db-postgres sharp graphql
// next.config.mjs — the Payload plugin is ESM, so use .mjs or "type": "module"
import { withPayload } from '@payloadcms/next/withPayload'

/** @type {import('next').NextConfig} */
const nextConfig = { /* your existing config */ }

export default withPayload(nextConfig)
// tsconfig.json — Payload resolves the config through this path alias
{ "compilerOptions": { "paths": { "@payload-config": ["./payload.config.ts"] } } }

Then copy the (payload) route group from the official blank template into your app/ folder and move your own routes into a second group such as (frontend). Those (payload) files are boilerplate: they mount the admin panel and the REST/GraphQL routes, and you never edit them.

3. Your first collection

Create collections/Posts.ts. This single file defines the database table, the admin form, the API shape and the types.

import type { CollectionConfig } from 'payload'

export const Posts: CollectionConfig = {
  slug: 'posts',
  admin: {
    useAsTitle: 'title',
    defaultColumns: ['title', 'status', 'updatedAt'],
  },
  access: {
    // signed-in users see everything, visitors only published posts
    read: ({ req: { user } }) =>
      user ? true : { status: { equals: 'published' } },
    create: ({ req: { user } }) => Boolean(user),
    update: ({ req: { user } }) => Boolean(user),
    delete: ({ req: { user } }) => user?.role === 'admin',
  },
  hooks: {
    beforeChange: [
      ({ data }) => {
        if (data?.title && !data.slug) {
          data.slug = data.title
            .toLowerCase()
            .replace(/[^a-z0-9]+/g, '-')
            .replace(/(^-|-$)/g, '')
        }
        return data
      },
    ],
  },
  fields: [
    { name: 'title', type: 'text', required: true },
    { name: 'slug', type: 'text', unique: true, index: true, admin: { position: 'sidebar' } },
    { name: 'content', type: 'richText' },
    {
      name: 'status',
      type: 'select',
      defaultValue: 'draft',
      options: ['draft', 'published'],
      admin: { position: 'sidebar' },
    },
    { name: 'publishedAt', type: 'date', admin: { position: 'sidebar' } },
    { name: 'author', type: 'relationship', relationTo: 'users' },
  ],
}

A users collection is one line of extra config — auth: true turns it into a login system with sessions, password hashing and role fields:

import type { CollectionConfig } from 'payload'

export const Users: CollectionConfig = {
  slug: 'users',
  auth: true,
  admin: { useAsTitle: 'email' },
  fields: [
    { name: 'name', type: 'text' },
    {
      name: 'role',
      type: 'select',
      defaultValue: 'editor',
      options: ['admin', 'editor'],
      saveToJWT: true,
    },
  ],
}

saveToJWT: true puts the role into the session token, which is what makes user?.role === 'admin' cheap in access functions.

4. Wire it into the config

// payload.config.ts
import { buildConfig } from 'payload'
import { lexicalEditor } from '@payloadcms/richtext-lexical'
import { postgresAdapter } from '@payloadcms/db-postgres'
import sharp from 'sharp'
import { Users } from './collections/Users'
import { Posts } from './collections/Posts'

export default buildConfig({
  editor: lexicalEditor(),
  collections: [Users, Posts],
  secret: process.env.PAYLOAD_SECRET || '',
  db: postgresAdapter({
    pool: { connectionString: process.env.DATABASE_URL },
  }),
  sharp,
  admin: { user: 'users' },
})
# .env
PAYLOAD_SECRET=a-long-random-unguessable-string
DATABASE_URL=postgres://user:password@localhost:5432/my_cms

In development the Postgres adapter pushes schema changes automatically, so a new field appears without a migration. That convenience is development-only — production needs real migrations:

npx payload migrate:create add_posts
npx payload migrate

Run migrate:create in CI or on deploy whenever the schema changes, or production will quietly lack the column your code expects. If you plan to query with Drizzle directly, also run npx payload generate:db-schema and import from the generated file.

5. Add a global for site-wide content

import type { GlobalConfig } from 'payload'

export const SiteSettings: GlobalConfig = {
  slug: 'site-settings',
  access: { read: () => true, update: ({ req: { user } }) => Boolean(user) },
  fields: [
    { name: 'siteName', type: 'text', required: true },
    { name: 'footerText', type: 'text' },
  ],
}

Add it to globals: [SiteSettings] in the config, then read it with await payload.findGlobal({ slug: 'site-settings' }).

6. Query the content from a React Server Component

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

export default async function BlogIndex() {
  const payload = await getPayload({ config })

  const { docs, totalDocs } = await payload.find({
    collection: 'posts',
    where: { status: { equals: 'published' } },
    sort: '-publishedAt',
    limit: 10,
    depth: 1,           // populate one level of relationships
    overrideAccess: true,
  })

  return (
    <main>
      <h1>Blog ({totalDocs})</h1>
      <ul>
        {docs.map((post) => (
          <li key={post.id}>
            <a href={`/blog/${post.slug}`}>{post.title}</a>
          </li>
        ))}
      </ul>
    </main>
  )
}

That await happens on the server, inside your app, against the database directly — no HTTP request to a CMS, no API key, no cold-start penalty on a third-party host. The same data is available over REST and GraphQL when a non-server client needs it:

curl "http://localhost:3000/api/posts?where[status][equals]=published&sort=-publishedAt&limit=10&depth=1"
# http://localhost:3000/api/graphql-playground
query {
  Posts(where: { status: { equals: published } }, sort: "-publishedAt", limit: 10) {
    docs { id title slug }
  }
}

7. The errors you will actually hit

SymptomCause and fix
Admin panel 404The (payload) route group is missing from app/, or your own routes were not moved into another group
require is not defined / ESM errorwithPayload imported from a CommonJS next.config.js — rename it next.config.mjs or set "type": "module"
Peer-dependency errors on installYour Next.js version is outside the supported ranges — pin a supported minor
Admin loads but every page errorsPAYLOAD_SECRET is empty, or DATABASE_URL points at a database that does not exist
Field added, column missing in productionYou pushed in development but never ran payload migrate in production
Visitors can read draftsThe read access rule returns true instead of a query constraint
Content invisible to a logged-in userAccess ran with the default overrideAccess: true in the Local API — pass false and a user

Key takeaways and challenge

Challenge: extend the Posts collection with a tags field of type array containing a label text field and a category relationship to a new Categories collection. Then add a beforeChange hook that stamps publishedAt only when status becomes published — and write down, before you code it, exactly which access rule stops publishedAt from being edited by an editor who is not an admin.

Want help modelling a real content schema, or moving a site onto Payload? Ampersand Academy offers one-to-one TypeScript and Next.js mentoring.

How do I install Payload CMS?

Run npx create-payload-app and answer the prompts, or add payload and @payloadcms/next plus a database adapter to an existing Next.js app, wrap your Next config with withPayload and create payload.config.ts.

What Node.js version does Payload need?

Node.js 20.18.1 or newer, as listed in its installation requirements. An older runtime may install successfully and then fail at runtime, so check your Node version before you scaffold.

Where is the Payload admin panel?

At the /admin route of your application, for example http://localhost:3000/admin in development. On an empty database the first visit asks you to create the initial admin user.

How do I run database migrations in Payload?

Development mode pushes schema changes automatically, but production does not. Generate a migration with npx payload migrate:create, then apply pending migrations with npx payload migrate during deployment.

Why can visitors see my draft posts?

Your read access rule is probably returning true for everyone. Return a query constraint instead, such as an object that requires status to equal published, so drafts never leave the database for anonymous requests.

Exit mobile version