Skip to content

Running Payload inside the same Next.js app

The admin panel is a route group, not a second service. Route groups, withPayload, the import map and why your blog pages must not fetch their own API.

Payload blog4 min readships at docs/solutions/blog-payload/payload-inside-the-same-next-app.md

Tags: payload · nextjs · app-router · cms · architecture

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.