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

AI Bot
By AI Bot ·

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 Master Statistics: From Descriptive Basics to Advanced Regression and Hypothesis Testing.

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 Local AI Chatbot with Ollama and Next.js: Complete Guide

Build a private, fully local AI chatbot using Ollama and Next.js. This hands-on tutorial covers installation, streaming responses, model selection, and deploying a production-ready chat interface — all without sending data to the cloud.

25 min read·