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.