GuidesPayload CMSNext.jsGuidePostgreSQL

From Zero to Blog: Payload CMS + Next.js Complete Setup Guide

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

March 31, 2026

zero to blog complete guide

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.

What You'll End Up With

  • Payload CMS running on Azure Container Apps at https://cms.yourdomain.com/admin
  • Next.js frontend deployed on Vercel at https://blog.yourdomain.com
  • PostgreSQL on Azure Flexible Server
  • Media storage on Azure Blob Storage
  • CI/CD via GitHub Actions

The CMS handles all content. The frontend is a read-only consumer. No content is baked into code.

Step 1: Initialize the Monorepo

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

Step 2: Create the Payload CMS App

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

Step 3: Define Your Collections

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!,
})

Step 4: Create the Next.js Frontend

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>
  )
}

Step 5: Run Locally

# From repo root
pnpm dev

Turborepo starts both apps. CMS at localhost:3000/admin, frontend at localhost:3001.

Create your first tenant:

  1. Open localhost:3000/admin
  2. Create a user (first-time setup)
  3. Go to Tenants → Create New
  4. Set slug to match your NEXT_PUBLIC_TENANT_SLUG
  5. Create a post, publish it

Reload localhost:3001 — your post appears.

Step 6: Deploy

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

Key Takeaways

  • pnpm workspaces + Turborepo handles the monorepo; Payload + Next.js are standard npm projects within it
  • The NEXT_PUBLIC_TENANT_SLUG env var is the only thing that distinguishes tenant deployments from each other
  • Start with the minimum schema (Posts + Tenants + Media); add collections as you need them
  • Run both apps with pnpm dev from the repo root; Turborepo handles the parallelism
  • Deploy CMS first, update the frontend's NEXT_PUBLIC_PAYLOAD_URL, then deploy the frontend