Building a Content-Driven Website with Payload CMS 3 and Next.js

Noqta Team
By Noqta Team ·

Loading the Text to Speech Audio Player...

Payload CMS 3 is the first headless CMS that lives inside your Next.js application. No separate server, no external API to manage — your CMS admin panel, API, and frontend all run as one unified Next.js app. In this tutorial, you will build a complete content-driven website from scratch.

What You'll Build

A TechPulse Blog — a full-featured content platform with:

  • Payload CMS 3 admin panel integrated directly into Next.js App Router
  • Collections for Posts, Categories, and Media
  • Rich text editor with Lexical (Payload's default editor)
  • Image uploads with automatic optimization
  • User authentication and role-based access control
  • Draft/publish workflow
  • REST and GraphQL APIs (auto-generated)
  • SEO metadata management
  • Responsive frontend with Tailwind CSS
  • Production deployment with PostgreSQL

Prerequisites

Before getting started, make sure you have:

  • Node.js 20+ installed
  • npm or pnpm as your package manager
  • Basic knowledge of React, TypeScript, and Next.js
  • A code editor (VS Code recommended)
  • Docker installed (for local PostgreSQL) or a cloud database URL
  • Basic understanding of content management systems

Why Payload CMS 3?

Payload CMS 3 represents a fundamental shift in how headless CMS platforms work. Instead of running as a separate backend service, Payload 3 embeds directly into your Next.js application:

FeaturePayload CMS 3StrapiSanityContentful
ArchitectureInside Next.jsSeparate serverHosted SaaSHosted SaaS
Admin UINext.js routeSeparate appStudio (React)Cloud dashboard
DatabasePostgres/MongoDBPostgres/MySQLHostedHosted
Type-safetyFull TypeScriptPartialGROQ typesSDK types
Self-hostedYes (included)Yes (separate)NoNo
APIREST + GraphQLREST + GraphQLGROQREST + GraphQL
CostFree & open-sourceFree tierFree tierFree tier
CustomizationFull code accessPlugin systemLimitedLimited

The key advantage: one codebase, one deployment, one mental model. Your CMS is just another part of your Next.js app.

Step 1: Create the Project

Payload provides an official create-payload-app CLI that scaffolds a complete project:

npx create-payload-app@latest techpulse

When prompted, select:

  • Project name: techpulse
  • Database: PostgreSQL (recommended for production)
  • Template: website (includes common collections)

If you want to use a local PostgreSQL via Docker:

docker run -d \
  --name payload-postgres \
  -e POSTGRES_USER=payload \
  -e POSTGRES_PASSWORD=payload123 \
  -e POSTGRES_DB=techpulse \
  -p 5432:5432 \
  postgres:16-alpine

Now set up and start the dev server:

cd techpulse
cp .env.example .env

Update your .env file:

DATABASE_URI=postgresql://payload:payload123@localhost:5432/techpulse
PAYLOAD_SECRET=your-super-secret-key-change-this-in-production
NEXT_PUBLIC_SITE_URL=http://localhost:3000
npm run dev

Visit http://localhost:3000/admin to create your first admin user. Your frontend will be at http://localhost:3000.

Step 2: Understand the Project Structure

Here is the key structure of a Payload 3 + Next.js project:

techpulse/
├── app/
│   ├── (frontend)/          # Your public website routes
│   │   ├── layout.tsx
│   │   ├── page.tsx
│   │   └── posts/
│   │       └── [slug]/
│   │           └── page.tsx
│   ├── (payload)/           # Payload admin panel routes
│   │   └── admin/
│   │       └── [[...segments]]/
│   │           └── page.tsx
│   ├── api/                 # Payload REST + GraphQL API
│   │   └── [...slug]/
│   │       └── route.ts
│   └── layout.tsx           # Root layout
├── collections/             # Payload collection configs
│   ├── Posts.ts
│   ├── Categories.ts
│   ├── Media.ts
│   └── Users.ts
├── globals/                 # Payload global configs
│   └── SiteSettings.ts
├── payload.config.ts        # Main Payload configuration
├── payload-types.ts         # Auto-generated TypeScript types
└── tailwind.config.ts

Notice how Payload uses Next.js route groups: (payload) for the admin panel and (frontend) for your public site. They coexist in the same Next.js app.

Step 3: Configure Payload

The heart of your CMS is payload.config.ts. Let's configure it:

// payload.config.ts
import { buildConfig } from 'payload'
import { postgresAdapter } from '@payloadcms/db-postgres'
import { lexicalEditor } from '@payloadcms/richtext-lexical'
import { uploadthingStorage } from '@payloadcms/storage-uploadthing'
import { Posts } from './collections/Posts'
import { Categories } from './collections/Categories'
import { Media } from './collections/Media'
import { Users } from './collections/Users'
import { SiteSettings } from './globals/SiteSettings'
import path from 'path'
import { fileURLToPath } from 'url'
import sharp from 'sharp'
 
const filename = fileURLToPath(import.meta.url)
const dirname = path.dirname(filename)
 
export default buildConfig({
  // Admin panel configuration
  admin: {
    user: Users.slug,
    meta: {
      titleSuffix: '- TechPulse Admin',
    },
  },
 
  // Collections (content types)
  collections: [Posts, Categories, Media, Users],
 
  // Globals (singleton content)
  globals: [SiteSettings],
 
  // Database adapter
  db: postgresAdapter({
    pool: {
      connectionString: process.env.DATABASE_URI || '',
    },
  }),
 
  // Rich text editor
  editor: lexicalEditor(),
 
  // Image processing
  sharp,
 
  // TypeScript output path
  typescript: {
    outputFile: path.resolve(dirname, 'payload-types.ts'),
  },
 
  // Secret for encryption
  secret: process.env.PAYLOAD_SECRET || '',
})

This single configuration file tells Payload everything about your CMS: what content types exist, which database to use, and how the admin panel should behave.

Step 4: Define Collections

Collections are the core building blocks of Payload. Each collection defines a content type with its fields, access control, and hooks.

Posts Collection

// collections/Posts.ts
import type { CollectionConfig } from 'payload'
import { lexicalEditor, HTMLConverterFeature } from '@payloadcms/richtext-lexical'
 
export const Posts: CollectionConfig = {
  slug: 'posts',
  admin: {
    useAsTitle: 'title',
    defaultColumns: ['title', 'category', 'status', 'publishedAt'],
    preview: (doc) => {
      if (doc?.slug) {
        return `${process.env.NEXT_PUBLIC_SITE_URL}/posts/${doc.slug}`
      }
      return null
    },
  },
 
  // Access control
  access: {
    read: ({ req }) => {
      // Published posts are public, drafts require authentication
      if (req.user) return true
      return {
        status: {
          equals: 'published',
        },
      }
    },
    create: ({ req }) => !!req.user,
    update: ({ req }) => !!req.user,
    delete: ({ req }) => !!req.user,
  },
 
  // Enable drafts
  versions: {
    drafts: {
      autosave: {
        interval: 30000, // Auto-save every 30 seconds
      },
    },
  },
 
  fields: [
    {
      name: 'title',
      type: 'text',
      required: true,
      minLength: 10,
      maxLength: 120,
    },
    {
      name: 'slug',
      type: 'text',
      required: true,
      unique: true,
      admin: {
        position: 'sidebar',
        description: 'URL-friendly identifier',
      },
      hooks: {
        beforeValidate: [
          ({ value, data }) => {
            // Auto-generate slug from title if not provided
            if (!value && data?.title) {
              return data.title
                .toLowerCase()
                .replace(/[^a-z0-9]+/g, '-')
                .replace(/(^-|-$)/g, '')
            }
            return value
          },
        ],
      },
    },
    {
      name: 'excerpt',
      type: 'textarea',
      required: true,
      maxLength: 300,
      admin: {
        description: 'Brief summary for cards and SEO',
      },
    },
    {
      name: 'coverImage',
      type: 'upload',
      relationTo: 'media',
      required: true,
    },
    {
      name: 'content',
      type: 'richText',
      required: true,
      editor: lexicalEditor({
        features: ({ defaultFeatures }) => [
          ...defaultFeatures,
          HTMLConverterFeature({}),
        ],
      }),
    },
    {
      name: 'category',
      type: 'relationship',
      relationTo: 'categories',
      required: true,
      hasMany: false,
      admin: {
        position: 'sidebar',
      },
    },
    {
      name: 'tags',
      type: 'array',
      admin: {
        position: 'sidebar',
      },
      fields: [
        {
          name: 'tag',
          type: 'text',
          required: true,
        },
      ],
    },
    {
      name: 'status',
      type: 'select',
      defaultValue: 'draft',
      options: [
        { label: 'Draft', value: 'draft' },
        { label: 'Published', value: 'published' },
      ],
      admin: {
        position: 'sidebar',
      },
    },
    {
      name: 'publishedAt',
      type: 'date',
      admin: {
        position: 'sidebar',
        date: {
          pickerAppearance: 'dayAndTime',
        },
      },
    },
    // SEO fields group
    {
      name: 'seo',
      type: 'group',
      fields: [
        {
          name: 'metaTitle',
          type: 'text',
          maxLength: 60,
          admin: {
            description: 'Overrides the post title for search engines',
          },
        },
        {
          name: 'metaDescription',
          type: 'textarea',
          maxLength: 160,
        },
        {
          name: 'ogImage',
          type: 'upload',
          relationTo: 'media',
        },
      ],
    },
  ],
 
  // Hooks for lifecycle events
  hooks: {
    beforeChange: [
      ({ data }) => {
        // Set publishedAt when status changes to published
        if (data.status === 'published' && !data.publishedAt) {
          data.publishedAt = new Date().toISOString()
        }
        return data
      },
    ],
  },
}

Categories Collection

// collections/Categories.ts
import type { CollectionConfig } from 'payload'
 
export const Categories: CollectionConfig = {
  slug: 'categories',
  admin: {
    useAsTitle: 'name',
  },
  access: {
    read: () => true,
    create: ({ req }) => !!req.user,
    update: ({ req }) => !!req.user,
    delete: ({ req }) => !!req.user,
  },
  fields: [
    {
      name: 'name',
      type: 'text',
      required: true,
      unique: true,
    },
    {
      name: 'slug',
      type: 'text',
      required: true,
      unique: true,
    },
    {
      name: 'description',
      type: 'textarea',
    },
    {
      name: 'color',
      type: 'text',
      admin: {
        description: 'Hex color for the category badge (e.g., #3B82F6)',
      },
    },
  ],
}

Media Collection

// collections/Media.ts
import type { CollectionConfig } from 'payload'
 
export const Media: CollectionConfig = {
  slug: 'media',
  admin: {
    useAsTitle: 'alt',
  },
  access: {
    read: () => true,
    create: ({ req }) => !!req.user,
    update: ({ req }) => !!req.user,
    delete: ({ req }) => !!req.user,
  },
  upload: {
    staticDir: 'public/media',
    mimeTypes: ['image/png', 'image/jpeg', 'image/webp', 'image/svg+xml'],
    imageSizes: [
      {
        name: 'thumbnail',
        width: 400,
        height: 300,
        position: 'centre',
      },
      {
        name: 'card',
        width: 768,
        height: 432,
        position: 'centre',
      },
      {
        name: 'hero',
        width: 1920,
        height: undefined,
        position: 'centre',
      },
    ],
  },
  fields: [
    {
      name: 'alt',
      type: 'text',
      required: true,
      admin: {
        description: 'Describe this image for accessibility',
      },
    },
    {
      name: 'caption',
      type: 'text',
    },
  ],
}

Users Collection

// collections/Users.ts
import type { CollectionConfig } from 'payload'
 
export const Users: CollectionConfig = {
  slug: 'users',
  auth: true, // Enables authentication
  admin: {
    useAsTitle: 'email',
  },
  access: {
    read: () => true,
    create: ({ req }) => req.user?.role === 'admin',
    update: ({ req, id }) => {
      if (req.user?.role === 'admin') return true
      return req.user?.id === id // Users can update themselves
    },
    delete: ({ req }) => req.user?.role === 'admin',
  },
  fields: [
    {
      name: 'name',
      type: 'text',
      required: true,
    },
    {
      name: 'role',
      type: 'select',
      defaultValue: 'editor',
      options: [
        { label: 'Admin', value: 'admin' },
        { label: 'Editor', value: 'editor' },
      ],
      access: {
        update: ({ req }) => req.user?.role === 'admin',
      },
      admin: {
        position: 'sidebar',
      },
    },
    {
      name: 'avatar',
      type: 'upload',
      relationTo: 'media',
    },
  ],
}

Step 5: Create Global Settings

Globals are singleton documents — content that exists only once, like site settings:

// globals/SiteSettings.ts
import type { GlobalConfig } from 'payload'
 
export const SiteSettings: GlobalConfig = {
  slug: 'site-settings',
  access: {
    read: () => true,
    update: ({ req }) => !!req.user,
  },
  fields: [
    {
      name: 'siteName',
      type: 'text',
      required: true,
      defaultValue: 'TechPulse',
    },
    {
      name: 'siteDescription',
      type: 'textarea',
      defaultValue: 'Your daily source for tech insights and tutorials.',
    },
    {
      name: 'logo',
      type: 'upload',
      relationTo: 'media',
    },
    {
      name: 'socialLinks',
      type: 'group',
      fields: [
        { name: 'twitter', type: 'text' },
        { name: 'github', type: 'text' },
        { name: 'linkedin', type: 'text' },
      ],
    },
    {
      name: 'footer',
      type: 'group',
      fields: [
        {
          name: 'copyright',
          type: 'text',
          defaultValue: '© 2026 TechPulse. All rights reserved.',
        },
        {
          name: 'links',
          type: 'array',
          fields: [
            { name: 'label', type: 'text', required: true },
            { name: 'url', type: 'text', required: true },
          ],
        },
      ],
    },
  ],
}

Step 6: Build the Frontend

Now let's build the public-facing website. Payload provides a getPayload function that gives you direct access to your CMS data — no API calls needed since Payload runs inside your Next.js app.

Layout Component

// app/(frontend)/layout.tsx
import type { Metadata } from 'next'
import { getPayload } from 'payload'
import config from '@payload-config'
import Link from 'next/link'
import './globals.css'
 
export async function generateMetadata(): Promise<Metadata> {
  const payload = await getPayload({ config })
  const settings = await payload.findGlobal({ slug: 'site-settings' })
 
  return {
    title: {
      default: settings.siteName,
      template: `%s | ${settings.siteName}`,
    },
    description: settings.siteDescription,
  }
}
 
export default async function FrontendLayout({
  children,
}: {
  children: React.ReactNode
}) {
  const payload = await getPayload({ config })
  const settings = await payload.findGlobal({ slug: 'site-settings' })
  const categories = await payload.find({
    collection: 'categories',
    limit: 10,
    sort: 'name',
  })
 
  return (
    <div className="min-h-screen flex flex-col">
      {/* Navigation */}
      <header className="border-b bg-white sticky top-0 z-50">
        <nav className="max-w-6xl mx-auto px-4 h-16 flex items-center justify-between">
          <Link href="/" className="text-xl font-bold text-gray-900">
            {settings.siteName}
          </Link>
 
          <div className="hidden md:flex items-center gap-6">
            {categories.docs.map((category) => (
              <Link
                key={category.id}
                href={`/category/${category.slug}`}
                className="text-sm text-gray-600 hover:text-gray-900 transition-colors"
              >
                {category.name}
              </Link>
            ))}
          </div>
        </nav>
      </header>
 
      {/* Main content */}
      <main className="flex-1">{children}</main>
 
      {/* Footer */}
      <footer className="border-t bg-gray-50 py-8">
        <div className="max-w-6xl mx-auto px-4 text-center text-sm text-gray-500">
          {settings.footer?.copyright}
        </div>
      </footer>
    </div>
  )
}

Home Page — Post Listing

// app/(frontend)/page.tsx
import { getPayload } from 'payload'
import config from '@payload-config'
import Link from 'next/link'
import Image from 'next/image'
import type { Post, Media, Category } from '@/payload-types'
 
export default async function HomePage() {
  const payload = await getPayload({ config })
 
  const posts = await payload.find({
    collection: 'posts',
    where: {
      status: { equals: 'published' },
    },
    sort: '-publishedAt',
    limit: 12,
    depth: 2, // Populate relationships (category, coverImage)
  })
 
  const [featured, ...rest] = posts.docs
 
  return (
    <div className="max-w-6xl mx-auto px-4 py-12">
      {/* Featured Post */}
      {featured && <FeaturedPost post={featured} />}
 
      {/* Post Grid */}
      <section className="mt-16">
        <h2 className="text-2xl font-bold mb-8">Latest Articles</h2>
        <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-8">
          {rest.map((post) => (
            <PostCard key={post.id} post={post} />
          ))}
        </div>
      </section>
    </div>
  )
}
 
function FeaturedPost({ post }: { post: Post }) {
  const coverImage = post.coverImage as Media
  const category = post.category as Category
 
  return (
    <Link href={`/posts/${post.slug}`} className="group block">
      <article className="grid md:grid-cols-2 gap-8 items-center">
        <div className="relative aspect-video rounded-xl overflow-hidden">
          {coverImage?.url && (
            <Image
              src={coverImage.url}
              alt={coverImage.alt}
              fill
              className="object-cover group-hover:scale-105 transition-transform duration-500"
              priority
            />
          )}
        </div>
        <div>
          {category && (
            <span
              className="inline-block px-3 py-1 text-xs font-medium rounded-full text-white mb-4"
              style={{ backgroundColor: category.color || '#3B82F6' }}
            >
              {category.name}
            </span>
          )}
          <h1 className="text-3xl font-bold mb-4 group-hover:text-blue-600 transition-colors">
            {post.title}
          </h1>
          <p className="text-gray-600 mb-4">{post.excerpt}</p>
          <time className="text-sm text-gray-400">
            {new Date(post.publishedAt!).toLocaleDateString('en-US', {
              year: 'numeric',
              month: 'long',
              day: 'numeric',
            })}
          </time>
        </div>
      </article>
    </Link>
  )
}
 
function PostCard({ post }: { post: Post }) {
  const coverImage = post.coverImage as Media
  const category = post.category as Category
 
  return (
    <Link href={`/posts/${post.slug}`} className="group block">
      <article>
        <div className="relative aspect-video rounded-lg overflow-hidden mb-4">
          {coverImage?.url && (
            <Image
              src={coverImage.url}
              alt={coverImage.alt}
              fill
              className="object-cover group-hover:scale-105 transition-transform duration-300"
            />
          )}
        </div>
        {category && (
          <span
            className="inline-block px-2 py-0.5 text-xs font-medium rounded-full text-white mb-2"
            style={{ backgroundColor: category.color || '#3B82F6' }}
          >
            {category.name}
          </span>
        )}
        <h3 className="font-semibold mb-2 group-hover:text-blue-600 transition-colors line-clamp-2">
          {post.title}
        </h3>
        <p className="text-sm text-gray-600 line-clamp-2">{post.excerpt}</p>
      </article>
    </Link>
  )
}

Single Post Page

// app/(frontend)/posts/[slug]/page.tsx
import { getPayload } from 'payload'
import config from '@payload-config'
import { notFound } from 'next/navigation'
import Image from 'next/image'
import type { Metadata } from 'next'
import type { Post, Media, Category } from '@/payload-types'
import { RichText } from '@payloadcms/richtext-lexical/react'
 
type Params = Promise<{ slug: string }>
 
export async function generateStaticParams() {
  const payload = await getPayload({ config })
  const posts = await payload.find({
    collection: 'posts',
    where: { status: { equals: 'published' } },
    limit: 100,
    select: { slug: true },
  })
 
  return posts.docs.map((post) => ({
    slug: post.slug,
  }))
}
 
export async function generateMetadata({
  params,
}: {
  params: Params
}): Promise<Metadata> {
  const { slug } = await params
  const payload = await getPayload({ config })
  const posts = await payload.find({
    collection: 'posts',
    where: { slug: { equals: slug } },
    limit: 1,
  })
 
  const post = posts.docs[0]
  if (!post) return { title: 'Post Not Found' }
 
  const coverImage = post.coverImage as Media
 
  return {
    title: post.seo?.metaTitle || post.title,
    description: post.seo?.metaDescription || post.excerpt,
    openGraph: {
      title: post.title,
      description: post.excerpt,
      images: coverImage?.url ? [{ url: coverImage.url }] : [],
    },
  }
}
 
export default async function PostPage({ params }: { params: Params }) {
  const { slug } = await params
  const payload = await getPayload({ config })
 
  const posts = await payload.find({
    collection: 'posts',
    where: {
      slug: { equals: slug },
      status: { equals: 'published' },
    },
    limit: 1,
    depth: 2,
  })
 
  const post = posts.docs[0]
  if (!post) notFound()
 
  const coverImage = post.coverImage as Media
  const category = post.category as Category
 
  return (
    <article className="max-w-3xl mx-auto px-4 py-12">
      {/* Header */}
      <header className="mb-8">
        {category && (
          <span
            className="inline-block px-3 py-1 text-xs font-medium rounded-full text-white mb-4"
            style={{ backgroundColor: category.color || '#3B82F6' }}
          >
            {category.name}
          </span>
        )}
        <h1 className="text-4xl font-bold mb-4">{post.title}</h1>
        <p className="text-lg text-gray-600 mb-4">{post.excerpt}</p>
        <time className="text-sm text-gray-400">
          {new Date(post.publishedAt!).toLocaleDateString('en-US', {
            year: 'numeric',
            month: 'long',
            day: 'numeric',
          })}
        </time>
      </header>
 
      {/* Cover Image */}
      {coverImage?.url && (
        <div className="relative aspect-video rounded-xl overflow-hidden mb-12">
          <Image
            src={coverImage.url}
            alt={coverImage.alt}
            fill
            className="object-cover"
            priority
          />
        </div>
      )}
 
      {/* Content */}
      <div className="prose prose-lg max-w-none">
        <RichText data={post.content} />
      </div>
 
      {/* Tags */}
      {post.tags && post.tags.length > 0 && (
        <div className="mt-12 pt-8 border-t flex flex-wrap gap-2">
          {post.tags.map((item, i) => (
            <span
              key={i}
              className="px-3 py-1 bg-gray-100 text-sm text-gray-600 rounded-full"
            >
              #{item.tag}
            </span>
          ))}
        </div>
      )}
    </article>
  )
}

Step 7: Add the Payload API Route

Payload auto-generates REST and GraphQL APIs. You need a catch-all route:

// app/api/[...slug]/route.ts
import { REST_DELETE, REST_GET, REST_PATCH, REST_POST } from '@payloadcms/next/routes'
import config from '@payload-config'
 
export const GET = REST_GET(config)
export const POST = REST_POST(config)
export const PATCH = REST_PATCH(config)
export const DELETE = REST_DELETE(config)

This gives you automatic API endpoints:

  • GET /api/posts — List all posts
  • GET /api/posts/:id — Get a single post
  • POST /api/posts — Create a post
  • PATCH /api/posts/:id — Update a post
  • DELETE /api/posts/:id — Delete a post
  • POST /api/users/login — Authenticate

The same works for every collection you define.

Step 8: Seed Initial Data

Create a seed script to populate your CMS with initial data:

// scripts/seed.ts
import { getPayload } from 'payload'
import config from '@payload-config'
 
async function seed() {
  const payload = await getPayload({ config })
 
  console.log('Seeding database...')
 
  // Create categories
  const categories = [
    { name: 'Engineering', slug: 'engineering', color: '#3B82F6', description: 'Software engineering and development' },
    { name: 'AI & ML', slug: 'ai-ml', color: '#8B5CF6', description: 'Artificial intelligence and machine learning' },
    { name: 'DevOps', slug: 'devops', color: '#10B981', description: 'Infrastructure, CI/CD, and operations' },
    { name: 'Design', slug: 'design', color: '#F59E0B', description: 'UI/UX design and design systems' },
  ]
 
  for (const category of categories) {
    await payload.create({
      collection: 'categories',
      data: category,
    })
    console.log(`Created category: ${category.name}`)
  }
 
  // Create a sample post
  const engineeringCategory = await payload.find({
    collection: 'categories',
    where: { slug: { equals: 'engineering' } },
    limit: 1,
  })
 
  await payload.create({
    collection: 'posts',
    data: {
      title: 'Getting Started with Payload CMS 3',
      slug: 'getting-started-payload-cms-3',
      excerpt: 'Learn the fundamentals of Payload CMS 3 and how it integrates with Next.js.',
      content: {
        root: {
          type: 'root',
          children: [
            {
              type: 'paragraph',
              children: [{ text: 'Welcome to your first Payload CMS 3 post!' }],
            },
          ],
          direction: 'ltr',
          format: '',
          indent: 0,
        },
      },
      category: engineeringCategory.docs[0]?.id,
      status: 'published',
      publishedAt: new Date().toISOString(),
    },
  })
 
  console.log('Seeding complete!')
  process.exit(0)
}
 
seed()

Run it:

npx tsx scripts/seed.ts

Step 9: Add Access Control Patterns

Payload's access control is one of its strongest features. Here are common patterns:

Role-Based Access

// lib/access.ts
import type { Access } from 'payload'
 
// Only admins can perform this action
export const isAdmin: Access = ({ req }) => {
  return req.user?.role === 'admin'
}
 
// Admins can see everything, others only their own documents
export const isAdminOrSelf: Access = ({ req }) => {
  if (req.user?.role === 'admin') return true
  return {
    id: { equals: req.user?.id },
  }
}
 
// Published content is public, drafts require auth
export const publishedOrAuth: Access = ({ req }) => {
  if (req.user) return true
  return {
    status: { equals: 'published' },
  }
}

Field-Level Access

// In your collection config
{
  name: 'internalNotes',
  type: 'textarea',
  access: {
    read: ({ req }) => req.user?.role === 'admin',
    update: ({ req }) => req.user?.role === 'admin',
  },
  admin: {
    condition: (data, siblingData, { user }) => user?.role === 'admin',
  },
}

Step 10: Add Collection Hooks

Hooks let you execute logic at different points in the document lifecycle:

// collections/Posts.ts — add to hooks
hooks: {
  beforeChange: [
    ({ data, operation }) => {
      // Auto-set publishedAt on first publish
      if (data.status === 'published' && !data.publishedAt) {
        data.publishedAt = new Date().toISOString()
      }
      return data
    },
  ],
  afterChange: [
    async ({ doc, operation }) => {
      if (operation === 'create' && doc.status === 'published') {
        // Trigger revalidation for ISR
        try {
          await fetch(`${process.env.NEXT_PUBLIC_SITE_URL}/api/revalidate`, {
            method: 'POST',
            headers: { 'Content-Type': 'application/json' },
            body: JSON.stringify({
              secret: process.env.REVALIDATION_SECRET,
              path: `/posts/${doc.slug}`,
            }),
          })
        } catch (error) {
          console.error('Revalidation failed:', error)
        }
      }
    },
  ],
}

Step 11: Configure On-Demand Revalidation

Create a revalidation API route so content updates reflect immediately:

// app/api/revalidate/route.ts
import { revalidatePath } from 'next/cache'
import { NextRequest, NextResponse } from 'next/server'
 
export async function POST(request: NextRequest) {
  const body = await request.json()
 
  if (body.secret !== process.env.REVALIDATION_SECRET) {
    return NextResponse.json({ message: 'Invalid secret' }, { status: 401 })
  }
 
  if (body.path) {
    revalidatePath(body.path)
    return NextResponse.json({ revalidated: true, path: body.path })
  }
 
  // Revalidate the home page by default
  revalidatePath('/')
  return NextResponse.json({ revalidated: true, path: '/' })
}

Step 12: Deploy to Production

Option A: Deploy to Vercel

Payload CMS 3 works seamlessly with Vercel. You will need a hosted PostgreSQL database (Vercel Postgres, Neon, or Supabase):

# Install Vercel CLI
npm i -g vercel
 
# Deploy
vercel

Set your environment variables in the Vercel dashboard:

DATABASE_URI=postgresql://...
PAYLOAD_SECRET=your-production-secret
NEXT_PUBLIC_SITE_URL=https://your-domain.com
REVALIDATION_SECRET=your-revalidation-secret

Option B: Deploy with Docker

Create a Dockerfile:

FROM node:20-alpine AS base
 
FROM base AS deps
WORKDIR /app
COPY package*.json ./
RUN npm ci
 
FROM base AS builder
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
RUN npm run build
 
FROM base AS runner
WORKDIR /app
ENV NODE_ENV=production
COPY --from=builder /app/public ./public
COPY --from=builder /app/.next/standalone ./
COPY --from=builder /app/.next/static ./.next/static
 
EXPOSE 3000
CMD ["node", "server.js"]

And a docker-compose.yml:

version: '3.8'
services:
  app:
    build: .
    ports:
      - '3000:3000'
    environment:
      - DATABASE_URI=postgresql://payload:payload123@db:5432/techpulse
      - PAYLOAD_SECRET=${PAYLOAD_SECRET}
      - NEXT_PUBLIC_SITE_URL=${NEXT_PUBLIC_SITE_URL}
    depends_on:
      - db
 
  db:
    image: postgres:16-alpine
    environment:
      - POSTGRES_USER=payload
      - POSTGRES_PASSWORD=payload123
      - POSTGRES_DB=techpulse
    volumes:
      - postgres_data:/var/lib/postgresql/data
    ports:
      - '5432:5432'
 
volumes:
  postgres_data:
docker compose up -d

Testing Your Implementation

  1. Admin Panel: Visit /admin, create a user, and log in
  2. Create Content: Add a category, upload an image, create a post
  3. Frontend: Visit the home page to see your posts rendered
  4. API: Test the REST API at /api/posts
  5. Draft/Publish: Create a draft post and verify it only shows when published
  6. Access Control: Log out and verify drafts are hidden

Troubleshooting

Common Issues

"Cannot find module '@payload-config'" Ensure your tsconfig.json has the path alias:

{
  "compilerOptions": {
    "paths": {
      "@payload-config": ["./payload.config.ts"]
    }
  }
}

Database connection errors Verify your DATABASE_URI is correct and the PostgreSQL server is running. For Docker:

docker ps  # Check if the container is running
docker logs payload-postgres  # Check for errors

Images not showing Ensure the public/media directory exists and has write permissions. For production, consider using cloud storage (S3, Cloudflare R2) with Payload's storage adapters.

Type errors after schema changes Regenerate types:

npx payload generate:types

Next Steps

Now that you have a working Payload CMS 3 + Next.js application, consider:

  • Add search using Payload's built-in search plugin or integrate Algolia
  • Add i18n with Payload's localization feature for multilingual content
  • Set up preview mode for editors to preview draft content on the live site
  • Add custom components to the admin panel using Payload's component overrides
  • Implement webhooks to notify external services when content changes
  • Add caching with Redis for improved performance at scale

Conclusion

You have built a complete content-driven website with Payload CMS 3 running natively inside Next.js. This architecture gives you the best of both worlds: a powerful admin panel and API with zero deployment complexity — everything runs as one application.

Key takeaways:

  • Payload CMS 3 lives inside Next.js — no separate backend to manage
  • Collections define your content model with full TypeScript types
  • Access control is code-first — flexible and testable
  • Hooks provide lifecycle automation — no need for external workflows
  • Direct database queries via getPayload() — no REST overhead on the server
  • Auto-generated APIs for external consumers who need REST or GraphQL

The Payload + Next.js combination is one of the most productive stacks for building content-heavy applications in 2026. You get a world-class CMS experience without sacrificing control over your codebase.


Want to read more tutorials? Check out our latest tutorial on Complete Guide to Setting Up OpenTelemetry with Next.js 15 for Production Observability.

Discuss Your Project with Us

We're here to help with your web development needs. Schedule a call to discuss your project and how we can assist you.

Let's find the best solutions for your needs.

Related Articles

Build a Full-Stack App with Strapi 5 and Next.js 15 App Router

Learn how to build a full-stack application with Strapi 5 as your headless CMS and Next.js 15 App Router for the frontend. This tutorial covers content modeling, REST APIs, server-side rendering, and production deployment.

30 min read·