Skip to content
Sign inJoin waitlist

Connect Payload CMS to a Next.js template

Payload is a free, open-source CMS that runs inside your Next.js project: the admin, the API and the website share one codebase and one deployment, and the content sits in a database you own. This guide adds it to the Nitip Next.js edition step by step — at the end, editors manage services, projects, posts, jobs, team and testimonials at /admin, and the site updates as soon as they save.

We followed every step below on a fresh copy of Nitip 1.0.0 with Payload 3.90.2 and Next.js 16.3, on a local machine. Every other Framebrick Next.js template works the same way: it reads its collections through the same src/lib/content.ts, so only the collections and fields differ — its Using a headless CMS page lists them.

  • An admin at /admin with a collection for each list on the site, image uploads, and drag-and-drop order.
  • The demo content imported — every entry and image — so the site looks exactly the same on day one.
  • Pages that update on save. Edit, add or delete an entry and the next visit shows it. No rebuild.
  • The same pages and components. Only src/lib/content.ts changes where it reads from.
  • The Nitip Next.js edition, unzipped, with npm install done and npm run dev working (Install and run).
  • Node.js 20.9 or newer.
  • About 45 minutes.

In the project folder:

Terminal window
npm install payload@3.90.2 @payloadcms/next@3.90.2 @payloadcms/richtext-lexical@3.90.2 @payloadcms/db-sqlite@3.90.2 sharp graphql

The versions are pinned so your project matches this guide. The database is SQLite — a single file in the project, nothing to set up. For hosting you’ll swap it for Postgres (Going live).

Payload only runs as an ES module, so add one line to package.json:

package.json
{
"name": "nitip",
"private": true,
"type": "module",

The admin needs its own page layout, separate from the site’s header and footer. Next.js does this with route groups — folders in parentheses that don’t change any URL.

  1. Create the folder src/app/(frontend).

  2. Move everything in src/app into it, except favicon.ico, robots.ts and sitemap.ts — those stay at the app root.

  3. In src/app/(frontend)/globals.css, the theme is now one folder further away:

    src/app/(frontend)/globals.css
    @import "tailwindcss";
    @import "../design-system/theme.css";
    @import "../../design-system/theme.css";

src/app now looks like this:

src/app/
├── (frontend)/ ← about, blog, careers, contact, projects, services, style-guide,
│ layout.tsx, page.tsx, not-found.tsx, globals.css, icons, share image
├── favicon.ico
├── robots.ts
└── sitemap.ts

Payload’s admin and API are a few route files you copy from Payload’s own starter, at the same version:

Terminal window
npx degit "payloadcms/payload/templates/blank/src/app/(payload)#v3.90.2" "src/app/(payload)"

That creates src/app/(payload) with the admin (admin/[[...segments]]), the REST and GraphQL API (api/…) and the admin’s layout. Leave these files as they are — Payload maintains them.

Three small edits.

tsconfig.json — Payload’s files import the config as @payload-config:

tsconfig.json
"paths": {
"@/*": ["./src/*"],
"@payload-config": ["./src/payload.config.ts"]
}

next.config.ts — wrap the config with withPayload:

next.config.ts
import { withPayload } from "@payloadcms/next/withPayload";
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
/* config options here */
};
export default withPayload(nextConfig);

.env — a new file in the project folder:

.env
# A long random string: Payload signs logins with it.
PAYLOAD_SECRET=replace-with-a-long-random-string
# The database: a SQLite file in the project folder.
DATABASE_URI=file:./nitip.db

Replace the secret with your own long random string; Payload signs logins with it. To make one:

Terminal window
node -e "console.log(require('crypto').randomBytes(32).toString('hex'))"

.env is already ignored by Git. Add the database file and the uploads folder too:

.gitignore
# payload
/media
*.db

Each collection mirrors a type in src/content/types.ts, field for field, so the pages get exactly the data they get today. Create src/cms/collections.ts:

src/cms/collections.ts
import { revalidatePath } from "next/cache";
import type { Block, CollectionAfterChangeHook, CollectionAfterDeleteHook, CollectionConfig, Field } from "payload";
/*
* The collections editors manage in /admin. Each one mirrors a type in src/content/types.ts, so src/lib/content.ts only
* has to rename a few fields on the way out.
*/
/* Saving publishes: every page is rebuilt from the CMS on its next visit (pages share content — a renamed service shows
on project pages too). The seed script turns it off. */
const revalidateAfterChange: CollectionAfterChangeHook = ({ doc, req }) => {
if (!req.context.disableRevalidate) revalidatePath("/", "layout");
return doc;
};
const revalidateAfterDelete: CollectionAfterDeleteHook = ({ doc, req }) => {
if (!req.context.disableRevalidate) revalidatePath("/", "layout");
return doc;
};
const hooks = { afterChange: [revalidateAfterChange], afterDelete: [revalidateAfterDelete] };
/* Shared fields */
const slug: Field = {
name: "slug",
type: "text",
required: true,
unique: true,
index: true,
admin: { position: "sidebar", description: "The page address: lowercase words joined by hyphens." },
};
const image = (name: string, label?: string): Field => ({ name, label, type: "upload", relationTo: "media", required: true });
const imagePair = (name: string): Field => ({ name, type: "upload", relationTo: "media", hasMany: true, minRows: 2, maxRows: 2, required: true });
/* Text that may start with a bold phrase ("Instant Traffic: Drive qualified visitors…"). */
const highlighted = (name: string): Field => ({
name,
type: "array",
fields: [
{ name: "highlight", type: "text", admin: { description: "Bold phrase at the start (optional)." } },
{ name: "text", type: "textarea" },
],
});
const lines = (name: string): Field => ({ name, type: "array", fields: [{ name: "text", type: "textarea", required: true }] });
/* Post body: the blocks the template renders (src/content/types.ts → PostBlock), two list levels deep. */
const heading: Block = { slug: "heading", fields: [{ name: "bold", type: "text" }, { name: "text", type: "text" }] };
const paragraph: Block = { slug: "paragraph", fields: [{ name: "highlight", type: "text" }, { name: "text", type: "textarea" }] };
const listFields = (inner: Block[]): Field[] => [
{ name: "ordered", type: "checkbox", label: "Numbered list" },
{ name: "items", type: "array", fields: [{ name: "content", type: "blocks", blocks: inner }] },
];
const subList: Block = { slug: "subList", labels: { singular: "List", plural: "Lists" }, fields: listFields([paragraph, heading]) };
const list: Block = { slug: "list", fields: listFields([paragraph, heading, subList]) };
/* Content collections are public to read (the site shows them anyway), editable by signed-in users, sorted by drag and
drop in the list, and published on save. */
const collection = (config: Omit<CollectionConfig, "access" | "hooks" | "orderable">): CollectionConfig => ({
...config,
access: { read: () => true },
hooks,
orderable: true,
});
export const Media: CollectionConfig = {
slug: "media",
access: { read: () => true },
upload: { staticDir: "media", mimeTypes: ["image/*"] },
fields: [{ name: "alt", type: "text" }],
};
export const Services = collection({
slug: "services",
admin: { useAsTitle: "title", defaultColumns: ["title", "slug"] },
fields: [
slug,
{ name: "title", type: "text", required: true },
{ name: "number", type: "text", admin: { description: "Shown before the title, e.g. 01." } },
imagePair("images"),
{ name: "description", type: "textarea" },
{ name: "tools", type: "array", fields: [{ name: "label", type: "text" }, { name: "href", type: "text" }] },
highlighted("offer"),
highlighted("benefits"),
{
name: "plans",
type: "array",
fields: [
{ name: "key", type: "text", required: true, admin: { description: "Short id, e.g. basic." } },
{ name: "label", type: "text" },
{ name: "price", type: "text" },
{ name: "note", type: "text" },
lines("included"),
],
},
],
});
export const Projects = collection({
slug: "projects",
admin: { useAsTitle: "title", defaultColumns: ["title", "year", "slug"] },
fields: [
slug,
{ name: "title", type: "text", required: true },
{ name: "year", type: "text" },
image("image", "Card image"),
{ name: "services", type: "relationship", relationTo: "services", hasMany: true },
{ name: "summary", type: "textarea" },
{ name: "industry", type: "text" },
{ name: "overview", type: "group", fields: [{ name: "text", type: "textarea" }, image("image")] },
{ name: "process", type: "group", fields: [{ name: "text", type: "textarea" }, imagePair("images")] },
{ name: "result", type: "group", fields: [highlighted("items"), image("image")] },
],
});
export const Posts = collection({
slug: "posts",
admin: { useAsTitle: "title", defaultColumns: ["title", "category", "date"] },
fields: [
slug,
{ name: "title", type: "text", required: true },
{ name: "excerpt", type: "textarea" },
image("image"),
{ name: "category", type: "text" },
/* dayOnly saves noon UTC, so the day stays the same in every time zone. */
{ name: "date", type: "date", required: true, admin: { date: { pickerAppearance: "dayOnly", displayFormat: "MMM d, yyyy" } } },
{ name: "body", type: "blocks", blocks: [heading, paragraph, list] },
],
});
export const Careers = collection({
slug: "careers",
admin: { useAsTitle: "title" },
fields: [slug, { name: "title", type: "text", required: true }, { name: "summary", type: "textarea" }, lines("responsibilities"), lines("requirements"), lines("benefits")],
});
export const Team = collection({
slug: "team",
labels: { singular: "Team member", plural: "Team" },
admin: { useAsTitle: "name" },
fields: [{ name: "name", type: "text", required: true }, { name: "role", type: "text" }, { name: "email", type: "email" }, image("photo")],
});
export const Testimonials = collection({
slug: "testimonials",
admin: { useAsTitle: "name" },
fields: [
{ name: "quote", type: "textarea", required: true },
{ name: "name", type: "text", required: true },
{ name: "company", type: "text" },
{ name: "service", type: "text", admin: { description: "The service the client used, shown under the name." } },
image("photo"),
image("logo"),
],
});

A few choices worth knowing:

  • Order. orderable: true gives each list drag handles in the admin. The site shows entries in that order, like the Framer CMS order the demo follows. New entries go to the end; drag them where you want them.
  • Publishing. The afterChange and afterDelete hooks call revalidatePath, so saving refreshes the site.
  • Post bodies are blocks — Heading, Paragraph and List — the same blocks the template renders. A list item holds its own blocks, and one more list level inside, which covers every post in the demo.
  • Pricing plans have a key field (weekly, monthly…) because Payload reserves id for its own row ids.
  • Dates use the day-only picker, which saves noon UTC — the day stays the same in every time zone.

Create src/payload.config.ts:

src/payload.config.ts
import path from "node:path";
import { fileURLToPath } from "node:url";
import { sqliteAdapter } from "@payloadcms/db-sqlite";
import { lexicalEditor } from "@payloadcms/richtext-lexical";
import { buildConfig } from "payload";
import sharp from "sharp";
import { Careers, Media, Posts, Projects, Services, Team, Testimonials } from "./cms/collections";
const dirname = path.dirname(fileURLToPath(import.meta.url));
export default buildConfig({
admin: { user: "users" },
collections: [
{ slug: "users", auth: true, admin: { useAsTitle: "email" }, fields: [] },
Media,
Services,
Projects,
Posts,
Careers,
Team,
Testimonials,
],
editor: lexicalEditor(),
secret: process.env.PAYLOAD_SECRET ?? "",
db: sqliteAdapter({ client: { url: process.env.DATABASE_URI ?? "file:./nitip.db" } }),
sharp,
typescript: { outputFile: path.resolve(dirname, "payload-types.ts") },
});

users is the login collection for the admin; the rest are the collections from step 5. Then let Payload generate two files — the admin’s component map and the TypeScript types for your collections (src/payload-types.ts):

Terminal window
npx payload generate:importmap
npx payload generate:types

Run both again whenever you change a collection.

The demo content still lives in src/content/collections/. This script copies it into Payload once, uploading every image from public/images to the Media collection. Create src/seed.ts:

src/seed.ts
/*
* Copies the demo content from src/content/collections into Payload, once:
* npx payload run src/seed.ts
* Images are uploaded from public/images to the Media collection. Run it on an empty database; it stops if services
* already exist.
*/
import path from "node:path";
import config from "@payload-config";
import { getPayload } from "payload";
import { careers } from "./content/collections/careers";
import { posts } from "./content/collections/posts";
import { projects } from "./content/collections/projects";
import { services } from "./content/collections/services";
import { team } from "./content/collections/team";
import { testimonials } from "./content/collections/testimonials";
import type { PostBlock } from "./content/types";
const payload = await getPayload({ config });
/* Pages are built from the database later, so there is nothing to revalidate while seeding. */
const context = { disableRevalidate: true };
if ((await payload.count({ collection: "services" })).totalDocs > 0) {
payload.logger.info("Services already exist — the database is seeded. Delete nitip.db and the media folder to start over.");
process.exit(0);
}
/* "/images/blog-1.jpg" → the id of its Media document, uploading each file once. */
const uploaded = new Map<string, number>();
async function media(src: string) {
if (!uploaded.has(src)) {
const doc = await payload.create({ collection: "media", data: { alt: "" }, filePath: path.resolve("public", src.slice(1)), context });
uploaded.set(src, doc.id);
}
return uploaded.get(src)!;
}
const mediaPair = async ([a, b]: [string, string]) => [await media(a), await media(b)];
const lines = (items: string[]) => items.map((text) => ({ text }));
/* A post body block → the Payload block, with lists inside lists becoming "subList". */
function block(item: PostBlock, nested = false): Record<string, unknown> {
if (item.type === "heading") return { blockType: "heading", text: item.text, bold: item.bold };
if (item.type === "paragraph") return { blockType: "paragraph", highlight: item.highlight, text: item.text };
return {
blockType: nested ? "subList" : "list",
ordered: item.ordered ?? false,
items: item.items.map((content) => ({ content: content.map((inner) => block(inner, true)) })),
};
}
const serviceIds = new Map<string, number>();
for (const service of services) {
const doc = await payload.create({
collection: "services",
context,
data: {
...service,
images: await mediaPair(service.images),
plans: service.plans.map(({ id, included, ...plan }) => ({ ...plan, key: id, included: lines(included) })),
},
});
serviceIds.set(service.slug, doc.id);
}
for (const project of projects) {
await payload.create({
collection: "projects",
context,
data: {
...project,
image: await media(project.image),
services: project.services.map((ref) => serviceIds.get(ref.slug)!),
overview: { text: project.overview.text, image: await media(project.overview.image) },
process: { text: project.process.text, images: await mediaPair(project.process.images) },
result: { items: project.result.items, image: await media(project.result.image) },
},
});
}
for (const post of posts) {
await payload.create({
collection: "posts",
context,
data: { ...post, image: await media(post.image), body: post.body.map((item) => block(item)) as never },
});
}
for (const career of careers) {
await payload.create({
collection: "careers",
context,
data: {
...career,
responsibilities: lines(career.responsibilities),
requirements: lines(career.requirements),
benefits: lines(career.benefits),
},
});
}
for (const member of team) {
await payload.create({ collection: "team", context, data: { ...member, photo: await media(member.photo) } });
}
for (const testimonial of testimonials) {
await payload.create({
collection: "testimonials",
context,
data: { ...testimonial, photo: await media(testimonial.photo), logo: await media(testimonial.logo) },
});
}
payload.logger.info(`Seeded ${services.length} services, ${projects.length} projects, ${posts.length} posts, ${careers.length} careers, ${team.length} team members, ${testimonials.length} testimonials and ${uploaded.size} images.`);
process.exit(0);

Run it:

Terminal window
npx payload run src/seed.ts

It creates the database (nitip.db), uploads the images to media/ and ends with:

Seeded 8 services, 8 projects, 12 posts, 12 careers, 6 team members, 3 testimonials and 80 images.

Replace src/lib/content.ts with this version. The function names and return types stay the same, so no page or component changes — they just get their data from Payload’s Local API, a direct database read with no HTTP in between:

src/lib/content.ts
/*
* Where pages get their collections — now from Payload (src/payload.config.ts). Each function reads a collection with
* the Local API (a direct database query, no HTTP) and maps the documents to the types in src/content/types.ts, so
* sections and design-system components don't change.
*
* `cache` makes each call run once per request, even when metadata and the page ask for the same entry.
*/
import config from "@payload-config";
import { getPayload } from "payload";
import { cache } from "react";
import type { Career, Highlighted, Post, PostBlock, Project, Service, TeamMember, Testimonial } from "@/content/types";
import type { Media, Post as PostDoc, Service as ServiceDoc } from "@/payload-types";
/* All documents of a collection in the order editors set in the admin (drag and drop). */
const all = cache(async <T extends "services" | "projects" | "posts" | "careers" | "team" | "testimonials">(collection: T) => {
const payload = await getPayload({ config });
const { docs } = await payload.find({ collection, sort: "_order", limit: 0, depth: 1 });
return docs;
});
/* Upload fields come back as Media documents (depth 1); the template only needs the file URL. */
const src = (image: number | Media | null | undefined) => (typeof image === "object" && image?.url) || "";
const pair = (images: (number | Media)[] | null | undefined): [string, string] => [src(images?.[0]), src(images?.[1])];
const highlighted = (items: { highlight?: string | null; text?: string | null }[] | null | undefined): Highlighted[] =>
(items ?? []).map((item) => ({ highlight: item.highlight ?? undefined, text: item.text ?? "" }));
const lines = (items: { text: string }[] | null | undefined) => (items ?? []).map((item) => item.text);
type BodyBlock = NonNullable<PostDoc["body"]>[number];
type InnerBlock = NonNullable<NonNullable<Extract<BodyBlock, { blockType: "list" }>["items"]>[number]["content"]>[number];
type DeepBlock = NonNullable<NonNullable<Extract<InnerBlock, { blockType: "subList" }>["items"]>[number]["content"]>[number];
function block(item: BodyBlock | InnerBlock | DeepBlock): PostBlock {
if (item.blockType === "heading") return { type: "heading", text: item.text ?? "", bold: item.bold ?? undefined };
if (item.blockType === "paragraph") return { type: "paragraph", highlight: item.highlight ?? undefined, text: item.text ?? "" };
return { type: "list", ordered: item.ordered ?? false, items: (item.items ?? []).map((row) => (row.content ?? []).map(block)) };
}
const toService = (doc: ServiceDoc): Service => ({
slug: doc.slug,
number: doc.number ?? "",
title: doc.title,
images: pair(doc.images),
description: doc.description ?? "",
tools: (doc.tools ?? []).map((tool) => ({ label: tool.label ?? "", href: tool.href ?? "" })),
offer: highlighted(doc.offer),
benefits: highlighted(doc.benefits),
plans: (doc.plans ?? []).map((plan) => ({
id: plan.key,
label: plan.label ?? "",
price: plan.price ?? "",
note: plan.note ?? "",
included: lines(plan.included),
})),
});
export const getProjects = cache(async (): Promise<Project[]> =>
(await all("projects")).map((doc) => ({
slug: doc.slug,
title: doc.title,
year: doc.year ?? "",
image: src(doc.image),
services: (doc.services ?? []).flatMap((service) => (typeof service === "object" ? [{ label: service.title, slug: service.slug }] : [])),
summary: doc.summary ?? "",
industry: doc.industry ?? "",
overview: { text: doc.overview.text ?? "", image: src(doc.overview.image) },
process: { text: doc.process.text ?? "", images: pair(doc.process.images) },
result: { items: highlighted(doc.result.items), image: src(doc.result.image) },
})),
);
export const getProject = cache(async (slug: string) => (await getProjects()).find((item) => item.slug === slug));
/** The project after this one, back to the first after the last ("Next Project" on a project page). */
export const getNextProject = cache(async (slug: string) => {
const projects = await getProjects();
return projects[(projects.findIndex((item) => item.slug === slug) + 1) % projects.length];
});
export const getServices = cache(async (): Promise<Service[]> => (await all("services")).map(toService));
export const getService = cache(async (slug: string) => (await getServices()).find((item) => item.slug === slug));
export const getPosts = cache(async (): Promise<Post[]> =>
(await all("posts")).map((doc) => ({
slug: doc.slug,
title: doc.title,
excerpt: doc.excerpt ?? "",
image: src(doc.image),
category: doc.category ?? "",
date: doc.date.slice(0, 10),
body: (doc.body ?? []).map(block),
})),
);
export const getPost = cache(async (slug: string) => (await getPosts()).find((item) => item.slug === slug));
export const getCareers = cache(async (): Promise<Career[]> =>
(await all("careers")).map((doc) => ({
slug: doc.slug,
title: doc.title,
summary: doc.summary ?? "",
responsibilities: lines(doc.responsibilities),
requirements: lines(doc.requirements),
benefits: lines(doc.benefits),
})),
);
export const getCareer = cache(async (slug: string) => (await getCareers()).find((item) => item.slug === slug));
export const getTeam = cache(async (): Promise<TeamMember[]> =>
(await all("team")).map((doc) => ({ name: doc.name, role: doc.role ?? "", email: doc.email ?? "", photo: src(doc.photo) })),
);
export const getTestimonials = cache(async (): Promise<Testimonial[]> =>
(await all("testimonials")).map((doc) => ({
quote: doc.quote,
name: doc.name,
company: doc.company ?? "",
service: doc.service ?? "",
photo: src(doc.photo),
logo: src(doc.logo),
})),
);

Two more edits so new entries and unknown addresses behave:

Let new entries render. The pages in blog/[slug], careers/[slug], projects/[slug] and services/[slug] (inside (frontend)) only allow the slugs that existed at build time. Allow new ones:

src/app/(frontend)/blog/[slug]/page.tsx
/* Only the posts in the collection exist; any other slug is a 404. */
export const dynamicParams = false;
/* Entries published in the CMS after the build render on their first visit; unknown slugs are a 404. */
export const dynamicParams = true;

Keep the site’s 404 page. With two layouts (site and admin) Next.js has no site-wide 404 any more, so an unknown address would show its plain default page. Catch every other address inside the site and show Nitip’s own. Create src/app/(frontend)/[...missing]/page.tsx:

src/app/(frontend)/[...missing]/page.tsx
import { notFound } from "next/navigation";
/* With two root layouts (site and admin) there is no app-wide 404, so any other URL lands here and shows the site's
not-found page inside the site layout. */
export default function Missing() {
notFound();
}
Terminal window
npm run dev
  1. Open localhost:3000/admin and create the first user — that’s your admin account.
  2. Open Posts, pick one, change the title and click Save.
  3. Open the post on the site (or the blog list, or the home page): the new title is there.
  4. Click Create New, fill in a post, save, and open /blog/<its slug>.

Then check the production build, where pages are pre-rendered and refreshed on save:

Terminal window
npm run build
npm start

Edit an entry in the admin again and reload the page — it shows the change without a new build.

  • Page copy — headings, intros, the About text, button labels — is in src/content/pages/, and the site details (name, contact, social links) in src/content/site.ts. They change rarely, so they stay in the repository. Move them to Payload globals later if editors need them.
  • src/content/collections/ keeps the demo data: the seed reads it, and the style guide page shows samples from it. The site’s pages no longer use it.
  • The contact and newsletter forms work as before (Forms).

The steps above run anywhere with a disk: on a server or a host with persistent storage (a VPS, Railway, Render with a disk), deploy as it is — npm run build, then npm start — with the same .env, and keep nitip.db and media/ between deploys.

Serverless hosts like Vercel have no lasting disk, so two things change. This part follows Payload’s own guides — we haven’t run it with every host, so check their docs for the details:

  1. Database: Postgres instead of SQLite. Install @payloadcms/db-postgres@3.90.2, use postgresAdapter in src/payload.config.ts with your Postgres URL (Neon, Supabase and others have free plans), and run the seed once against it. In production, Payload changes the database through migrations (Postgres, Migrations).
  2. Images: cloud storage instead of media/. Add a storage adapter — Vercel Blob, S3, Cloudflare R2 and more (Storage adapters). If the images are then served from another domain, allow it in next.config.ts under images.remotePatterns.

Set PAYLOAD_SECRET and the database URL in the host’s environment variables, never in Git. Payload’s deployment guide covers the rest.

ERR_REQUIRE_ASYNC_MODULE when running a payload command. package.json is missing "type": "module" (step 1).

Can't resolve '../design-system/theme.css'. The import in src/app/(frontend)/globals.css still has one ../ (step 2).

Cannot find module '@payload-config'. The path in tsconfig.json is missing (step 4). Restart npm run dev after adding it.

Unknown addresses show a plain “404: This page could not be found.” The catch-all page from step 8 is missing.

The seed says the database is already seeded. It only runs on an empty database. To start over, stop the dev server, delete nitip.db and the media folder, and run it again.

A new post shows a 404. dynamicParams is still false in that collection’s [slug]/page.tsx (step 8).

Still stuck? Contact support with the step and the error message.