-
Notifications
You must be signed in to change notification settings - Fork 3.5k
Description
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'istrue→datais included - In production (minified build): webpack/terser mangles class names →
APIErrorbecomestore→ the check isfalse→ falls through to the generic branch which only returnsmessage→datais 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
beforeLoginhook that throwsnew 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— observedataPreserved: true - Build for production (
next build && next start) — call/api/test-2fa-error— observedataPreserved: falseandconstructorName: "t"(mangled) - Attempt login with a 2FA-enabled user in production — the 2FA TOTP input never appears because
error.data.requires2FAisundefined
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