Describe the Bug
In production (Next.js App Router, standard next start, not standalone output), every request to /admin/* deterministically crashes:
TypeError: Cannot destructure property 'routes' of '{}' as it is undefined.
at RootPage (.next/server/app/(payload)/admin/[[...segments]]/page.js)
digest: '987409242'
TypeError: Cannot destructure property 'config' of '<awaited-value>' as it is undefined.
at generatePageMetadata-related fn (.next/server/app/(payload)/admin/[[...segments]]/page.js)
digest: '1425221127'
This traces to RootPage's own top-level destructure (@payloadcms/next/dist/views/Root/index.js):
export const RootPage = async ({ config: configPromise, importMap, params: paramsPromise, searchParams: searchParamsPromise }) => {
const config = await configPromise;
const {
admin: { routes: { createFirstUser: _createFirstUserRoute }, user: userSlug },
routes: { admin: adminRoute }
} = config;
...
config.admin is resolving to {} (an empty object, not undefined) — i.e. config itself is a real, truthy object, but its admin property lacks the routes sub-object that sanitizeConfig's addDefaultsToConfig is supposed to populate unconditionally.
Notably
- The REST API route (
/api/[...slug]) in the same app, same deployment, same request window, works correctly — it resolves config via Payload's getPayload() singleton (req.payload.config), not via the raw @payload-config module import.
- Our
apps/cms/src/app/(payload)/admin/[[...segments]]/page.tsx follows the officially scaffolded pattern exactly:
import config from '@payload-config'
import { RootPage, generatePageMetadata } from '@payloadcms/next/views'
import { importMap } from '../../importMap.js'
export const generateMetadata = ({ params, searchParams }) => generatePageMetadata({ config, params, searchParams })
const Page = ({ params, searchParams }) => RootPage({ config, params, searchParams, importMap })
export default Page
payload.config.ts's default export is a single export default buildConfig({...}) — buildConfig (from payload/dist/config/build.js) is async function buildConfig(config): Promise<SanitizedConfig>, internally calling sanitizeConfig(config), whose first synchronous step is addDefaultsToConfig(incomingConfig) — which unconditionally sets admin.routes defaults (createFirstUser: '/create-first-user', etc.). We traced this directly in node_modules/payload/dist/config/sanitize.js and defaults.js — nothing conditional should cause admin.routes to be dropped.
- We could not determine, from static source reading alone, why the
config value awaited inside RootPage's own module differs from the one Payload's getPayload() singleton produces from the same buildConfig() promise export — our leading hypothesis is that Next.js compiles the admin page/metadata bundle and the REST API route bundle as separate serverless-style chunks, each independently re-evaluating payload.config.ts, and something about that independent re-evaluation (possibly concurrent/racy) produces an incompletely-resolved config specifically in the page/metadata bundle. We were not able to confirm this without a debugger attached to a live reproduction.
Why this is easy to miss
Before we had run any Payload migrations, our /admin route 500'd earlier in the request lifecycle (relation "users" does not exist), so this create-first-user redirect code path was never actually exercised. Once the schema existed and the users table was genuinely empty, every /admin request started taking this exact path — turning what might otherwise show up as a rare, easy-to-miss race into a 100%-reproducible outage the moment a fresh Postgres-backed deployment has zero users.
To Reproduce
- Fresh Postgres database,
prodMigrations wired into postgresAdapter() (migrations apply via payload.init() on server boot — no separate payload migrate step).
NODE_ENV=production, next build && next start.
- Ensure the
users table exists but has zero rows.
- Request
/admin or /admin/create-first-user — the request should redirect toward create-first-user and crash with the above error.
Environment
- Payload: 3.85.1
- @payloadcms/next: 3.85.1
- @payloadcms/db-postgres: ^3.0.0
- Next.js: 15.4.11
- React: 19.x / React DOM 19.x
- Node: 20.x (Railway-hosted, no custom Dockerfile — platform-managed build)
- Adapter:
@payloadcms/db-postgres with prodMigrations
Expected behavior
/admin/create-first-user should render normally when the users table exists and is empty — this is the standard first-run admin bootstrap flow.
Additional context
Happy to share more evidence (deploy logs, exact digests) or run a targeted reproduction if a maintainer can point at what in sanitizeConfig's call chain could differ between RootPage's config import and getPayload()'s resolved config within the same Next.js app.
Describe the Bug
In production (Next.js App Router, standard
next start, not standalone output), every request to/admin/*deterministically crashes:This traces to
RootPage's own top-level destructure (@payloadcms/next/dist/views/Root/index.js):config.adminis resolving to{}(an empty object, notundefined) — i.e.configitself is a real, truthy object, but itsadminproperty lacks theroutessub-object thatsanitizeConfig'saddDefaultsToConfigis supposed to populate unconditionally.Notably
/api/[...slug]) in the same app, same deployment, same request window, works correctly — it resolves config via Payload'sgetPayload()singleton (req.payload.config), not via the raw@payload-configmodule import.apps/cms/src/app/(payload)/admin/[[...segments]]/page.tsxfollows the officially scaffolded pattern exactly:payload.config.ts's default export is a singleexport default buildConfig({...})—buildConfig(frompayload/dist/config/build.js) isasync function buildConfig(config): Promise<SanitizedConfig>, internally callingsanitizeConfig(config), whose first synchronous step isaddDefaultsToConfig(incomingConfig)— which unconditionally setsadmin.routesdefaults (createFirstUser: '/create-first-user', etc.). We traced this directly innode_modules/payload/dist/config/sanitize.jsanddefaults.js— nothing conditional should causeadmin.routesto be dropped.configvalue awaited insideRootPage's own module differs from the one Payload'sgetPayload()singleton produces from the samebuildConfig()promise export — our leading hypothesis is that Next.js compiles the admin page/metadata bundle and the REST API route bundle as separate serverless-style chunks, each independently re-evaluatingpayload.config.ts, and something about that independent re-evaluation (possibly concurrent/racy) produces an incompletely-resolved config specifically in the page/metadata bundle. We were not able to confirm this without a debugger attached to a live reproduction.Why this is easy to miss
Before we had run any Payload migrations, our
/adminroute 500'd earlier in the request lifecycle (relation "users" does not exist), so thiscreate-first-userredirect code path was never actually exercised. Once the schema existed and theuserstable was genuinely empty, every/adminrequest started taking this exact path — turning what might otherwise show up as a rare, easy-to-miss race into a 100%-reproducible outage the moment a fresh Postgres-backed deployment has zero users.To Reproduce
prodMigrationswired intopostgresAdapter()(migrations apply viapayload.init()on server boot — no separatepayload migratestep).NODE_ENV=production,next build && next start.userstable exists but has zero rows./adminor/admin/create-first-user— the request should redirect toward create-first-user and crash with the above error.Environment
@payloadcms/db-postgreswithprodMigrationsExpected behavior
/admin/create-first-usershould render normally when theuserstable exists and is empty — this is the standard first-run admin bootstrap flow.Additional context
Happy to share more evidence (deploy logs, exact digests) or run a targeted reproduction if a maintainer can point at what in
sanitizeConfig's call chain could differ betweenRootPage's config import andgetPayload()'s resolved config within the same Next.js app.