Describe the Bug
Summary
In production builds (Next.js 16 with webpack minification), formatErrors.ts silently drops the data field from APIError responses. This causes any feature that relies on error.data in the client (e.g., 2FA flows that check data.requires2FA) to break in production while working perfectly in development.
Affected File
packages/payload/src/utilities/formatErrors.ts
Root Cause
formatErrors.ts uses proto.constructor.name string comparison to identify APIError and ValidationError instances:
// Cannot use instanceof to check error type
// https://github.com/microsoft/TypeScript/issues/13965
// Instead, get the prototype of the incoming error and check its constructor name
const proto = Object.getPrototypeOf(incoming)
if (
(proto.constructor.name === ValidationErrorName ||
proto.constructor.name === APIErrorName) &&
incoming.data
) {
return { errors: [{ name: incoming.name, data: incoming.data, message: incoming.message }] }
}
- In development: class names are preserved →
proto.constructor.name === 'APIError' is true → data is included
- In production (minified build): webpack/terser mangles class names →
APIError becomes t or e → the check is false → falls through to the generic branch which only returns message → data is silently dropped
Why instanceof Would Actually Work Here
The comment references TypeScript issue #13965, where instanceof fails for classes extending Error with target: ES5. However, APIError already applies the standard fix for this — it calls Object.setPrototypeOf(this, new.target.prototype) in its constructor via ExtendableError:
// node_modules/payload/dist/errors/APIError.js
class ExtendableError extends Error {
constructor(message) {
super(message)
this.name = this.constructor.name
if (typeof Error.captureStackTrace === 'function') {
Error.captureStackTrace(this, this.constructor)
} else {
this.stack = new Error(message).stack
}
Object.setPrototypeOf(this, new.target.prototype) // this fixes instanceof
}
}
This means instanceof APIError works correctly and would be the safe fix.
Reproduction — Diagnostic Endpoint
Add this route to a Next.js Payload project and call it in both dev and production:
// src/app/api/test-2fa-error/route.ts
import { NextResponse } from 'next/server'
import { APIError } from 'payload'
export async function GET() {
const err = new APIError('test error', 428, { requires2FA: true })
const proto = Object.getPrototypeOf(err)
const constructorName = proto.constructor.name
const constructorNameMatch = constructorName === 'APIError' // Replicate the exact check from formatErrors.ts
let formattedData: any = null
if (constructorNameMatch && err.data) {
formattedData = err.data // data is preserved
} else {
// data is dropped — the bug
}
return NextResponse.json({
pass: constructorNameMatch,
constructorName, // 'APIError' in dev, 't' or 'e' in prod
constructorNameMatch, // true in dev, false in prod
instanceofWorks: err instanceof APIError, // true in BOTH
dataPreserved: formattedData !== null,
})
}
Dev response:
{ "pass": true, "constructorName": "APIError", "constructorNameMatch": true, "instanceofWorks": true, "dataPreserved": true }
Production response (minified build):
{ "pass": false, "constructorName": "t", "constructorNameMatch": false, "instanceofWorks": true, "dataPreserved": false }
Note: instanceofWorks is true in both environments, confirming instanceof is the correct fix.
Real-World Impact
In our application, we use a 2FA flow where the beforeLogin hook throws:
throw new APIError('2FA required', 428, { requires2FA: true })
The client checks error.data.requires2FA to show the TOTP input. In production, data is dropped by formatErrors, so the client never sees requires2FA: true and the 2FA input never appears — breaking login for all 2FA-enabled users.
This worked in Next.js 15 (less aggressive minification) and broke after upgrading to Next.js 16 canary, which uses more aggressive class name mangling.
Proposed Fix
Replace the constructor name check with instanceof:
// packages/payload/src/utilities/formatErrors.ts
// BEFORE (broken in production):
const proto = Object.getPrototypeOf(incoming)
if (
(proto.constructor.name === ValidationErrorName ||
proto.constructor.name === APIErrorName) &&
incoming.data
) { ... }
// AFTER (works in all environments):
if (
(incoming instanceof ValidationError || incoming instanceof APIError) &&
incoming.data
) { ... }
Since APIError and ValidationError both call Object.setPrototypeOf(this, new.target.prototype) in their constructors, instanceof works correctly even with TypeScript target: ES5 — the original reason for avoiding it no longer applies.
Workaround
Until this is fixed in Payload, the afterError global hook can re-inject the data field:
// payload.config.ts
hooks: {
afterError: [async ({ error }) => {
const err = error as any
if (err?.status === 428 && err?.data?.requires2FA) {
return { response: { errors: [{ message: err.message, data: err.data }] } }
}
}],
}
Environment
- Payload 3.79.0
- Next.js 16.2.0-canary.9 (also reproducible with any Next.js version that minifies server-side class names)
- Node.js 20
Link to the code that reproduces this issue
https://github.com/payloadcms/payload/blob/main/packages/payload/src/utilities/formatErrors.ts
Reproduction Steps
- Create a Next.js + Payload project
- Add a
beforeLogin hook that throws new APIError('2FA required', 428, { requires2FA: true })
- Add the diagnostic route from
src/app/api/test-2fa-error/route.ts (see description above)
- Run in development (
next dev) — call /api/test-2fa-error — observe dataPreserved: true
- Build for production (
next build && next start) — call /api/test-2fa-error — observe dataPreserved: false and constructorName: "t" (mangled)
- Attempt login with a 2FA-enabled user in production — the 2FA TOTP input never appears because
error.data.requires2FA is undefined
Which area(s) are affected?
area: core
Environment Info
Binaries:
Node: 23.11.1
npm: 10.9.2
Yarn: 1.22.22
pnpm: 10.16.1
Relevant Packages:
payload: 3.79.0
next: 16.2.0-canary.9
@payloadcms/db-mongodb: 3.79.0
@payloadcms/email-nodemailer: 3.79.0
@payloadcms/graphql: 3.79.0
@payloadcms/next/utilities: 3.79.0
@payloadcms/payload-cloud: 3.79.0
@payloadcms/plugin-cloud-storage: 3.79.0
@payloadcms/plugin-import-export: 3.79.0
@payloadcms/richtext-lexical: 3.79.0
@payloadcms/storage-gcs: 3.79.0
@payloadcms/translations: 3.79.0
@payloadcms/ui/shared: 3.79.0
react: 19.2.4
react-dom: 19.2.4
Operating System:
Platform: darwin
Arch: arm64
Version: Darwin Kernel Version 25.2.0: Tue Nov 18 21:07:05 PST 2025; root:xnu-12377.61.12~1/RELEASE_ARM64_T6020
Available memory (MB): 32768
Available CPU cores: 10
Describe the Bug
Summary
In production builds (Next.js 16 with webpack minification),
formatErrors.tssilently drops thedatafield fromAPIErrorresponses. This causes any feature that relies onerror.datain the client (e.g., 2FA flows that checkdata.requires2FA) to break in production while working perfectly in development.Affected File
packages/payload/src/utilities/formatErrors.tsRoot Cause
formatErrors.tsusesproto.constructor.namestring comparison to identifyAPIErrorandValidationErrorinstances:proto.constructor.name === 'APIError'istrue→datais includedAPIErrorbecomestore→ the check isfalse→ falls through to the generic branch which only returnsmessage→datais silently droppedWhy
instanceofWould Actually Work HereThe comment references TypeScript issue #13965, where
instanceoffails for classes extendingErrorwithtarget: ES5. However,APIErroralready applies the standard fix for this — it callsObject.setPrototypeOf(this, new.target.prototype)in its constructor viaExtendableError:This means
instanceof APIErrorworks correctly and would be the safe fix.Reproduction — Diagnostic Endpoint
Add this route to a Next.js Payload project and call it in both dev and production:
Dev response:
{ "pass": true, "constructorName": "APIError", "constructorNameMatch": true, "instanceofWorks": true, "dataPreserved": true }Production response (minified build):
{ "pass": false, "constructorName": "t", "constructorNameMatch": false, "instanceofWorks": true, "dataPreserved": false }Note:
instanceofWorksistruein both environments, confirminginstanceofis the correct fix.Real-World Impact
In our application, we use a 2FA flow where the
beforeLoginhook throws:The client checks
error.data.requires2FAto show the TOTP input. In production,datais dropped byformatErrors, so the client never seesrequires2FA: trueand the 2FA input never appears — breaking login for all 2FA-enabled users.This worked in Next.js 15 (less aggressive minification) and broke after upgrading to Next.js 16 canary, which uses more aggressive class name mangling.
Proposed Fix
Replace the constructor name check with
instanceof:Since
APIErrorandValidationErrorboth callObject.setPrototypeOf(this, new.target.prototype)in their constructors,instanceofworks correctly even with TypeScripttarget: ES5— the original reason for avoiding it no longer applies.Workaround
Until this is fixed in Payload, the
afterErrorglobal hook can re-inject thedatafield:Environment
Link to the code that reproduces this issue
https://github.com/payloadcms/payload/blob/main/packages/payload/src/utilities/formatErrors.ts
Reproduction Steps
beforeLoginhook that throwsnew APIError('2FA required', 428, { requires2FA: true })src/app/api/test-2fa-error/route.ts(see description above)next dev) — call/api/test-2fa-error— observedataPreserved: truenext build && next start) — call/api/test-2fa-error— observedataPreserved: falseandconstructorName: "t"(mangled)error.data.requires2FAisundefinedWhich area(s) are affected?
area: core
Environment Info