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.
What you get
Section titled “What you get”- An admin at
/adminwith 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.tschanges where it reads from.
Before you start
Section titled “Before you start”- The Nitip Next.js edition, unzipped, with
npm installdone andnpm run devworking (Install and run). - Node.js 20.9 or newer.
- About 45 minutes.
1. Install Payload
Section titled “1. Install Payload”In the project folder:
npm install payload@3.90.2 @payloadcms/next@3.90.2 @payloadcms/richtext-lexical@3.90.2 @payloadcms/db-sqlite@3.90.2 sharp graphqlThe 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:
{ "name": "nitip", "private": true, "type": "module",2. Move the site into a route group
Section titled “2. Move the site into a route group”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.
-
Create the folder
src/app/(frontend). -
Move everything in
src/appinto it, exceptfavicon.ico,robots.tsandsitemap.ts— those stay at the app root. -
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.ts3. Add the admin and API routes
Section titled “3. Add the admin and API routes”Payload’s admin and API are a few route files you copy from Payload’s own starter, at the same version:
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.
4. Point Next.js at Payload
Section titled “4. Point Next.js at Payload”Three small edits.
tsconfig.json — Payload’s files import the config as @payload-config:
"paths": { "@/*": ["./src/*"], "@payload-config": ["./src/payload.config.ts"] }next.config.ts — wrap the config with withPayload:
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:
# 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.dbReplace the secret with your own long random string; Payload signs logins with it. To make one:
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:
# payload/media*.db5. Describe the collections
Section titled “5. Describe the collections”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:
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: truegives 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
afterChangeandafterDeletehooks callrevalidatePath, 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
keyfield (weekly,monthly…) because Payload reservesidfor its own row ids. - Dates use the day-only picker, which saves noon UTC — the day stays the same in every time zone.
6. Create the Payload config
Section titled “6. Create the Payload config”Create 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):
npx payload generate:importmapnpx payload generate:typesRun both again whenever you change a collection.
7. Import the demo content
Section titled “7. Import the demo content”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:
/* * 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:
npx payload run src/seed.tsIt 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.8. Read the pages from Payload
Section titled “8. Read the pages from Payload”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:
/* * 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:
/* 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:
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();}9. Try it
Section titled “9. Try it”npm run dev- Open localhost:3000/admin and create the first user — that’s your admin account.
- Open Posts, pick one, change the title and click Save.
- Open the post on the site (or the blog list, or the home page): the new title is there.
- 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:
npm run buildnpm startEdit an entry in the admin again and reload the page — it shows the change without a new build.
What stays in the code
Section titled “What stays in the code”- Page copy — headings, intros, the About text, button labels — is in
src/content/pages/, and the site details (name, contact, social links) insrc/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).
Going live
Section titled “Going live”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:
- Database: Postgres instead of SQLite. Install
@payloadcms/db-postgres@3.90.2, usepostgresAdapterinsrc/payload.config.tswith 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). - 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 innext.config.tsunderimages.remotePatterns.
Set PAYLOAD_SECRET and the database URL in the host’s environment variables, never in Git. Payload’s
deployment guide covers the rest.
Troubleshooting
Section titled “Troubleshooting”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.
Learn more
Section titled “Learn more”- Payload installation — the general version of steps 1–4
- Collections and fields
- Local API — how
src/lib/content.tsreads the data - Access control — roles beyond “signed in can edit”
- Use a headless CMS with a Next.js template — how every template’s content layer works, and the other CMS options