Payload 3 runs inside a Next.js app. There is no separate Express server, no
second deployment and no CORS configuration: the admin panel is a set of routes
in your app/ directory, and your pages read the database through a function
call.
That is a genuinely different mental model from every other CMS, and the pieces only make sense together.
The three moving parts
A route group that owns the CMS. Everything Payload serves lives in
src/app/(payload)/:
src/app/(payload)/
layout.tsx Payload's own root layout and CSS
cms/importMap.ts generated: which custom components exist
cms/[[...segments]]/page.tsx the entire admin UI
cms/[[...segments]]/not-found.tsx 404s inside the panel, not on your site
cms-api/[...slug]/route.ts REST, for the admin bundle in the browser
cms-api/graphql/route.ts GraphQL
The parentheses matter. A route group does not appear in the URL, but it does get its own root layout, which is exactly what you need, because Payload ships its own reset, fonts and design system, and your site's header has no business wrapping an admin panel.
A config file at the root. payload.config.ts names the collections, the
database adapter and the editor. Both the admin routes and your pages import it.
A build-time wrapper. next.config.ts has to be wrapped:
import { withPayload } from "@payloadcms/next/withPayload";
export default withPayload(nextConfig);
Skip it and /cms throws a module-resolution error rather than rendering. This
is the single most common "Payload does not work" report.
Move the routes off the defaults
Payload defaults to /admin and /api. Both are worth changing:
routes: { admin: "/cms", api: "/cms-api" },
/admin collides with the admin panel many apps already have. /api is worse:
Payload mounts a catch-all route there, so it sits in the same tree as every
other API route in your app. Next.js resolves static segments before catch-alls
so it usually works, but "usually" is not a property you want in your routing
table. Give the CMS its own prefix and stop thinking about it.
When you change routes.admin, the folder name has to match (cms/[[...segments]]) because that is the URL Next.js serves. The import map moves with it: the
generator resolves src/app/(payload)<routes.admin>/importMap.js, so on the
default route it lands in admin/ and here it lands in cms/. Leave a copy
behind in admin/ and nothing warns you: the panel just keeps importing an
empty map while the generator writes a full one next door.
The import map
importMap.ts is how the server tells the admin bundle which custom components
exist. It starts empty:
export const importMap = {};
The extension is not Payload's default. Left alone the generator writes
JavaScript, and an untyped .js import in a strict app is a hole the compiler
cannot see through, so payload.config.ts names the file instead:
admin: {
importMap: {
baseDir: path.resolve(dirname, "src/app/(payload)"),
importMapFile: path.resolve(dirname, "src/app/(payload)/cms/importMap.ts"),
},
},
and is regenerated whenever you add a custom field component, view or widget:
bun run payload:importmap
Commit it. A clean checkout must be able to build the admin panel without running Payload's CLI first. If you add a custom component and forget to regenerate, the admin panel renders the default component and gives you no warning.
Read with the local API, not over HTTP
This is the part that most repays understanding. Because Payload is in the same process, your pages can query it directly:
import { getPayload } from "payload";
import config from "../../payload.config";
export async function getPayloadClient() {
return getPayload({ config });
}
const posts = await payload.find({
collection: "posts",
where: { _status: { equals: "published" } },
sort: "-publishedAt",
depth: 0,
limit: 20,
});
That is a database query. Compare it with the version people write out of habit:
// don't
const res = await fetch(`${process.env.NEXT_PUBLIC_APP_URL}/cms-api/posts?where[_status][equals]=published`);
The fetch version leaves the process, opens a TCP connection to your own deployment, possibly wakes a cold serverless function, runs the same query, serialises every field to JSON and parses it again: to reach code that was already loaded in memory. It also loses the request's identity, so access rules see an anonymous caller and you get published rows only, which is sometimes what you wanted and never what you reasoned about.
/cms-api has exactly two legitimate callers: the admin panel's browser bundle,
and genuine external consumers such as a mobile app.
The gotcha nobody warns you about
The local API defaults to overrideAccess: true. It is trusted server code, so
Payload assumes you know what you are doing:
// returns drafts and unpublished documents
await payload.find({ collection: "posts" });
On a public page, that means your read access rule does nothing. Either filter
explicitly and treat the filter as security-relevant, or pass
overrideAccess: false with the user you resolved from the request.
What it costs
Two honest downsides of the single-app model:
Cold starts. The admin routes pull in Payload, the database driver and the editor bundle. The first request after idle is slow. It is an internal tool, so this is usually fine, but tell your editors before they file it as a bug.
Shared connection budget. The admin panel and your app use the same
database. On serverless, point DATABASE_URL at a pooled endpoint and treat the
pool as a shared resource.
In exchange you get one deployment, one repository, one set of environment variables, no CORS, no API keys between your own services, and content that joins to your application tables in a single query.