End-to-end guide: Payload CMS + Next.js 16 + PostgreSQL + Vercel — from blank repo to live blog in one walkthrough.
March 31, 2026

Most "build a blog" tutorials end at "here's how to render Markdown." This one ends at a live URL with a CMS you can actually use. Here's the complete path from an empty directory to a deployed Payload CMS + Next.js blog.
https://cms.yourdomain.com/adminhttps://blog.yourdomain.comThe CMS handles all content. The frontend is a read-only consumer. No content is baked into code.
mkdir blog && cd blog
git init
# Create workspace structure
mkdir -p apps/cms apps/web
# Root package.json
cat > package.json << 'EOF'
{
"name": "blog-monorepo",
"private": true,
"workspaces": ["apps/*"],
"scripts": {
"dev": "turbo dev",
"build": "turbo build"
},
"devDependencies": {
"turbo": "^2.0.0"
}
}
EOF
npm install -g pnpm
pnpm install
cd apps/cms
pnpm create payload-app@latest .
# Choose: blank template, PostgreSQL adapter, TypeScript
This scaffolds a Payload 3.0 project with Next.js as the server adapter. The admin panel lives at http://localhost:3000/admin.
Configure apps/cms/.env:
DATABASE_URI=postgresql://blog:blogpass@localhost:5432/blogdb
PAYLOAD_SECRET=your-super-secret-key-change-in-production
PORT=3000
Start PostgreSQL with Docker:
# docker-compose.yml at repo root
cat > ../../docker-compose.yml << 'EOF'
services:
postgres:
image: postgres:16
environment:
POSTGRES_DB: blogdb
POSTGRES_USER: blog
POSTGRES_PASSWORD: blogpass
ports:
- "5432:5432"
volumes:
- postgres_data:/var/lib/postgresql/data
volumes:
postgres_data:
EOF
cd ../..
docker-compose up -d
The minimum viable schema: Tenants, Posts, Categories, Media.
// apps/cms/src/collections/Posts.ts
export const Posts: CollectionConfig = {
slug: 'posts',
admin: { useAsTitle: 'title' },
access: { read: () => true },
fields: [
{ name: 'title', type: 'text', required: true },
{ name: 'slug', type: 'text', required: true, unique: true },
{ name: 'description', type: 'textarea', required: true },
{ name: 'mdxContent', type: 'code', admin: { language: 'mdx' } },
{
name: 'content',
type: 'richText',
required: true,
editor: lexicalEditor({ features: ({ rootFeatures }) => rootFeatures }),
},
{
name: 'tenant',
type: 'relationship',
relationTo: 'tenants',
required: true,
index: true,
},
{
name: 'status',
type: 'select',
required: true,
defaultValue: 'draft',
options: ['draft', 'published'],
},
{ name: 'publishedAt', type: 'date' },
{ name: 'featuredImage', type: 'upload', relationTo: 'media' },
],
}
Register collections in payload.config.ts:
export default buildConfig({
collections: [Posts, Tenants, Categories, Media, Users],
db: postgresAdapter({ pool: { connectionString: process.env.DATABASE_URI } }),
secret: process.env.PAYLOAD_SECRET!,
})
cd apps/web
pnpm create next-app@latest . --typescript --tailwind --app --no-src-dir --import-alias "@/*"
Configure apps/web/.env.local:
NEXT_PUBLIC_PAYLOAD_URL=http://localhost:3000
NEXT_PUBLIC_APP_URL=http://localhost:3001
NEXT_PUBLIC_TENANT_SLUG=my-blog
The core data fetching functions:
// apps/web/lib/payload-client.ts
const BASE = process.env.NEXT_PUBLIC_PAYLOAD_URL
const SLUG = process.env.NEXT_PUBLIC_TENANT_SLUG
export async function getPosts() {
const tenant = await getTenant()
if (!tenant) return []
const res = await fetch(
`${BASE}/api/posts?where[tenant][equals]=${tenant.id}&where[status][equals]=published&sort=-publishedAt`,
{ next: { revalidate: 60 } }
)
return (await res.json()).docs ?? []
}
export async function getPostBySlug(slug: string) {
const res = await fetch(
`${BASE}/api/posts?where[slug][equals]=${slug}&limit=1`,
{ next: { revalidate: 60 } }
)
return (await res.json()).docs?.[0] ?? null
}
The homepage (app/page.tsx):
export default async function HomePage() {
const posts = await getPosts()
return (
<main className="max-w-2xl mx-auto px-4 py-12">
<h1 className="text-3xl font-bold mb-8">Latest Posts</h1>
<div className="space-y-6">
{posts.map((post: any) => (
<article key={post.id}>
<a href={`/${post.slug}`} className="group">
<h2 className="text-xl font-semibold group-hover:underline">{post.title}</h2>
<p className="text-gray-600 mt-1">{post.description}</p>
</a>
</article>
))}
</div>
</main>
)
}
# From repo root
pnpm dev
Turborepo starts both apps. CMS at localhost:3000/admin, frontend at localhost:3001.
Create your first tenant:
localhost:3000/adminNEXT_PUBLIC_TENANT_SLUGReload localhost:3001 — your post appears.
CMS → Azure Container Apps (see the deploying-payload-cms-azure post for full details)
Frontend → Vercel:
cd apps/web
pnpm dlx vercel --prod
Set environment variables in Vercel:
NEXT_PUBLIC_PAYLOAD_URL=https://cms.yourdomain.com
NEXT_PUBLIC_APP_URL=https://blog.yourdomain.com
NEXT_PUBLIC_TENANT_SLUG=my-blog
NEXT_PUBLIC_TENANT_SLUG env var is the only thing that distinguishes tenant deployments from each otherpnpm dev from the repo root; Turborepo handles the parallelismNEXT_PUBLIC_PAYLOAD_URL, then deploy the frontend