# 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 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:

```ts
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:

```ts
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:

```ts
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:

```ts
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:

```bash
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:

```ts
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:

```ts
// 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:

```ts
// 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.

---

Agentic Boilerplate: A Next.js repo your agent already knows. Free during launch, then $99 once.

- Site map for agents: https://agenticboilerplate.com/llms.txt
- Public API: https://agenticboilerplate.com/openapi.json
- Contact: agenticstudio@gmail.com
