Skip to content

RootPage: config.admin resolves to {} on every request in production, causing 'Cannot destructure property routes of {}' — app config's own module import, not getPayload()'s #17409

Description

@mamathakonduru

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

  1. Fresh Postgres database, prodMigrations wired into postgresAdapter() (migrations apply via payload.init() on server boot — no separate payload migrate step).
  2. NODE_ENV=production, next build && next start.
  3. Ensure the users table exists but has zero rows.
  4. 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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Fields

    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions