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

Authenticate Your Next.js 15 App with Auth.js v5: Email, OAuth, and Role-Based Access
Learn how to add production-ready authentication to your Next.js 15 application using Auth.js v5. This comprehensive guide covers Google OAuth, email/password credentials, protected routes, middleware, and role-based access control.

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.

Building an Autonomous AI Agent with Agentic RAG and Next.js
Learn how to build an AI agent that autonomously decides when and how to retrieve information from vector databases. A comprehensive hands-on guide using Vercel AI SDK and Next.js with executable examples.