Skip to main content
Back to Blog
Tutorials
3 min read
November 19, 2024

How to Set Up a CMS with Payload in Next.js

Set up Payload CMS inside your Next.js app. Self-hosted, type-safe, fully customizable content management.

Ryel Banfield

Founder & Lead Developer

Payload is a headless CMS that lives inside your Next.js app. No separate service to deploy. It provides an admin panel, content types, authentication, and a fully typed API.

Step 1: Create a New Payload + Next.js Project

npx create-payload-app@latest my-project

Choose:

  • Database: PostgreSQL (or MongoDB)
  • Template: blank

This scaffolds a Next.js project with Payload pre-configured.

Step 2: Add Payload to an Existing Next.js Project

pnpm add payload @payloadcms/next @payloadcms/db-postgres @payloadcms/richtext-lexical

Step 3: Configure Payload

// payload.config.ts
import { buildConfig } from "payload";
import { postgresAdapter } from "@payloadcms/db-postgres";
import { lexicalEditor } from "@payloadcms/richtext-lexical";
import { Pages } from "./collections/Pages";
import { Posts } from "./collections/Posts";
import { Media } from "./collections/Media";
import { Users } from "./collections/Users";

export default buildConfig({
  admin: {
    user: Users.slug,
  },
  collections: [Pages, Posts, Media, Users],
  editor: lexicalEditor(),
  db: postgresAdapter({
    pool: {
      connectionString: process.env.DATABASE_URI!,
    },
  }),
  secret: process.env.PAYLOAD_SECRET!,
  typescript: {
    outputFile: "src/payload-types.ts",
  },
});

Step 4: Create Collections

Posts Collection

// collections/Posts.ts
import { CollectionConfig } from "payload";

export const Posts: CollectionConfig = {
  slug: "posts",
  admin: {
    useAsTitle: "title",
    defaultColumns: ["title", "status", "publishedDate"],
  },
  access: {
    read: () => true,
  },
  fields: [
    {
      name: "title",
      type: "text",
      required: true,
    },
    {
      name: "slug",
      type: "text",
      required: true,
      unique: true,
      admin: {
        position: "sidebar",
      },
    },
    {
      name: "excerpt",
      type: "textarea",
      maxLength: 200,
    },
    {
      name: "content",
      type: "richText",
      required: true,
    },
    {
      name: "featuredImage",
      type: "upload",
      relationTo: "media",
    },
    {
      name: "category",
      type: "select",
      options: [
        { label: "Web Design", value: "web-design" },
        { label: "Web Development", value: "web-development" },
        { label: "Business", value: "business" },
        { label: "Tutorial", value: "tutorial" },
      ],
    },
    {
      name: "tags",
      type: "array",
      fields: [
        {
          name: "tag",
          type: "text",
        },
      ],
    },
    {
      name: "status",
      type: "select",
      defaultValue: "draft",
      options: [
        { label: "Draft", value: "draft" },
        { label: "Published", value: "published" },
      ],
      admin: {
        position: "sidebar",
      },
    },
    {
      name: "publishedDate",
      type: "date",
      admin: {
        position: "sidebar",
      },
    },
    {
      name: "author",
      type: "relationship",
      relationTo: "users",
      admin: {
        position: "sidebar",
      },
    },
  ],
};

Media Collection

// collections/Media.ts
import { CollectionConfig } from "payload";

export const Media: CollectionConfig = {
  slug: "media",
  upload: {
    staticDir: "public/media",
    imageSizes: [
      { name: "thumbnail", width: 400, height: 300 },
      { name: "card", width: 768, height: 512 },
      { name: "hero", width: 1920, height: 1080 },
    ],
    mimeTypes: ["image/*"],
  },
  access: {
    read: () => true,
  },
  fields: [
    {
      name: "alt",
      type: "text",
      required: true,
    },
  ],
};

Users Collection

// collections/Users.ts
import { CollectionConfig } from "payload";

export const Users: CollectionConfig = {
  slug: "users",
  auth: true,
  admin: {
    useAsTitle: "email",
  },
  fields: [
    {
      name: "name",
      type: "text",
    },
    {
      name: "role",
      type: "select",
      options: [
        { label: "Admin", value: "admin" },
        { label: "Editor", value: "editor" },
      ],
      defaultValue: "editor",
    },
  ],
};

Step 5: Query Data in Server Components

Payload provides a local API you call directly in server components:

// app/blog/page.tsx
import { getPayload } from "payload";
import configPromise from "@payload-config";

export default async function BlogPage() {
  const payload = await getPayload({ config: configPromise });

  const posts = await payload.find({
    collection: "posts",
    where: {
      status: { equals: "published" },
    },
    sort: "-publishedDate",
    limit: 12,
  });

  return (
    <main className="mx-auto max-w-7xl px-6 py-12">
      <h1 className="text-3xl font-bold">Blog</h1>
      <div className="mt-8 grid gap-6 md:grid-cols-2 lg:grid-cols-3">
        {posts.docs.map((post) => (
          <article key={post.id} className="rounded-lg border p-6 dark:border-gray-700">
            <h2 className="text-lg font-semibold">{post.title}</h2>
            <p className="mt-2 text-sm text-gray-500">{post.excerpt}</p>
          </article>
        ))}
      </div>
    </main>
  );
}

Step 6: Dynamic Blog Post Pages

// app/blog/[slug]/page.tsx
import { getPayload } from "payload";
import configPromise from "@payload-config";
import { notFound } from "next/navigation";

export async function generateStaticParams() {
  const payload = await getPayload({ config: configPromise });
  const posts = await payload.find({
    collection: "posts",
    where: { status: { equals: "published" } },
    limit: 1000,
  });

  return posts.docs.map((post) => ({ slug: post.slug }));
}

export default async function BlogPost({
  params,
}: {
  params: Promise<{ slug: string }>;
}) {
  const { slug } = await params;
  const payload = await getPayload({ config: configPromise });

  const posts = await payload.find({
    collection: "posts",
    where: {
      slug: { equals: slug },
      status: { equals: "published" },
    },
    limit: 1,
  });

  const post = posts.docs[0];
  if (!post) notFound();

  return (
    <article className="mx-auto max-w-3xl px-6 py-12">
      <h1 className="text-4xl font-bold">{post.title}</h1>
      <div className="prose mt-8 dark:prose-invert">
        {/* Render rich text content */}
      </div>
    </article>
  );
}

Step 7: Environment Variables

# .env
DATABASE_URI=postgresql://localhost:5432/my-cms
PAYLOAD_SECRET=your-secret-key-here-minimum-32-chars

Advantages of Payload

  • Lives inside your Next.js app (no separate service)
  • Fully type-safe with auto-generated TypeScript types
  • Admin panel included at /admin
  • Local API for server-side queries (no HTTP overhead)
  • Self-hosted: you own your data
  • Extensible with custom fields, hooks, and endpoints

Need a CMS-Powered Website?

We build custom CMS solutions with Payload for businesses that need full content control. Contact us to discuss your project.

CMSPayloadNext.jsheadless CMStutorial

Ready to Start Your Project?

RCB Software builds world-class websites and applications for businesses worldwide.

Get in Touch

Related Articles