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

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:
| Feature | Payload CMS 3 | Strapi | Sanity | Contentful |
|---|---|---|---|---|
| Architecture | Inside Next.js | Separate server | Hosted SaaS | Hosted SaaS |
| Admin UI | Next.js route | Separate app | Studio (React) | Cloud dashboard |
| Database | Postgres/MongoDB | Postgres/MySQL | Hosted | Hosted |
| Type-safety | Full TypeScript | Partial | GROQ types | SDK types |
| Self-hosted | Yes (included) | Yes (separate) | No | No |
| API | REST + GraphQL | REST + GraphQL | GROQ | REST + GraphQL |
| Cost | Free & open-source | Free tier | Free tier | Free tier |
| Customization | Full code access | Plugin system | Limited | Limited |
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 techpulseWhen 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-alpineNow set up and start the dev server:
cd techpulse
cp .env.example .envUpdate 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:3000npm run devVisit 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 postsGET /api/posts/:id— Get a single postPOST /api/posts— Create a postPATCH /api/posts/:id— Update a postDELETE /api/posts/:id— Delete a postPOST /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.tsStep 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
vercelSet 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 -dTesting Your Implementation
- Admin Panel: Visit
/admin, create a user, and log in - Create Content: Add a category, upload an image, create a post
- Frontend: Visit the home page to see your posts rendered
- API: Test the REST API at
/api/posts - Draft/Publish: Create a draft post and verify it only shows when published
- 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 errorsImages 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:typesNext 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.
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

Building a Modern Website with Sanity v3 and Next.js App Router
Learn how to build a content-driven website using Sanity v3 as your headless CMS with Next.js App Router. This tutorial covers schema design, GROQ queries, live preview, image handling, and production deployment.

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.

Jotai 2 — Atomic State Management for React and Next.js: From Zero to Production
Master atomic state management in React with Jotai 2. This hands-on tutorial covers primitive atoms, derived atoms, async atoms, persistent storage, SSR hydration with Next.js App Router, and real-world patterns for building scalable applications.