Custom API routes
Replace the built-in endpoints with your own Next.js routes, and wire authentication so Payload access control applies.
The built-in endpoints cover most cases. Replace them only when you need custom authentication or middleware: set enableEndpoints: false and build routes with the factories from @delmaredigital/payload-puck/api.
| Factory | Route pattern | Methods |
|---|---|---|
createPuckApiRoutes | /api/puck/[collection] | GET (list), POST (create) |
createPuckApiRoutesWithId | /api/puck/[collection]/[id] | GET, PATCH, DELETE |
createPuckApiRoutesVersions | /api/puck/[collection]/[id]/versions | GET (list versions) |
createPuckApiRoutesVersions | /api/puck/[collection]/[id]/restore | POST (restore) |
import { createPuckApiRoutesWithId } from '@delmaredigital/payload-puck/api'
import { getPayload } from 'payload'
import config from '@payload-config'
export const { GET, PATCH, DELETE } = createPuckApiRoutesWithId({
collection: 'pages',
payloadConfig: config,
auth: {
authenticate: async (request) => {
const payload = await getPayload({ config })
const { user } = await payload.auth({ headers: request.headers })
if (!user) return { authenticated: false }
return {
authenticated: true,
user: { ...user, id: String(user.id) },
payloadUser: user,
}
},
canDelete: async (user) => ({ allowed: user.role === 'admin' }),
},
})The factory returns both handlers. Mount GET at versions and POST at restore: the editor posts restores to the latter path.
export { POST } from '../versions/route'The starter files include all four routes and a complete versions handler.
Options
Prop
Type
Auth hooks
Prop
Type
Each canX hook returns { allowed, error? }, synchronously or as a promise. Leaving one out adds no restriction beyond Payload's collection access.
Access control
Breaking in 0.9.0: security fix
These factories used to call Payload's Local API with the default overrideAccess: true, so collection and field access rules were never evaluated on them (GHSA-957g-hmmp-rchg). Any caller your authenticate hook accepted could read version history and drafts, restore versions, publish, create and delete. They now enforce access control, which requires knowing which Payload user the request acts as.
authenticate and the canX hooks are authentication plus coarse route gating. They are not authorization; Payload's collection access rules are. Both layers apply.
The recommended wiring, for every auth system
Build authenticate on payload.auth(). It runs whatever auth strategies your Payload config registers (Better Auth, Clerk, custom strategies) and returns a real Payload user. Pass it unchanged as payloadUser for access checks. The separate user object is for route gating; its ID must be a string.
import type { PuckApiAuthHooks } from '@delmaredigital/payload-puck/api'
import { getPayload } from 'payload'
import config from '@payload-config'
export const auth: PuckApiAuthHooks = {
authenticate: async (request) => {
const payload = await getPayload({ config })
const { user } = await payload.auth({ headers: request.headers })
if (!user) return { authenticated: false }
return {
authenticated: true,
user: { ...user, id: String(user.id) },
payloadUser: user,
}
},
}Don't return your auth library's session user
auth.api.getSession(), getServerSession(), getServerUser() and decoded JWTs return objects that are not Payload users. They carry no collection, and these routes fail closed on them.
Mapping one back to a user by email is worse than the error: a bare collection row silently drops the fields your auth strategy decorates onto the user. With payload-better-auth that means losing activeOrganizationId, organizationRole, apiKeyScopes and oauthScopes, so an API-key caller can be judged as an ordinary session and an org-scoped rule can be evaluated with no organization at all.
Better Auth users
Wire strategies: [betterAuthStrategy()] on your users collection as usual, then use the payload.auth() recipe above. That is the whole integration.
Any published @delmaredigital/payload-better-auth works, since its strategy stamps collection on the user. 0.9.0 or later is recommended: it is the first release where this wiring, combined with the headers now forwarded into Payload, is free of side effects for API-key requests.
Better Auth reads API keys from the x-api-key header by default. Authorization: Bearer <api key> is not authenticated unless you configure apiKeyHeaders or a custom getter.
AuthResult.user.id currently accepts only strings, while Payload may return a numeric ID. Convert the ID only on the route-gating user copy and keep the original user in payloadUser, including every field added by your auth strategy.
Callers with no Payload user
Only if a caller genuinely has no corresponding Payload user, implement toPayloadUser. Whatever you return is the principal access control evaluates, so it must carry every field your access rules read. Return null to deliberately act as an anonymous request.
If authenticate already looked the Payload user up, return it as payloadUser and omit toPayloadUser to save the second lookup. The routes use toPayloadUser whenever it is defined, even if authenticate returned payloadUser. Otherwise they use payloadUser when it is not undefined (including null for anonymous access), then user if it is structurally a Payload user (it has a collection); otherwise they fail closed with PuckApiAccessError.
Behaviour to expect
- Fails closed. If a route can't determine the Payload user, it returns
500with codePUCK_ACCESS_MISCONFIGUREDand performs no database operation. The server log carries the exact fix. It never silently downgrades to an anonymous request or skips authorization. - Request headers reach your access rules. The routes forward the original request headers into every Payload operation, so an access rule that inspects them (an API-key scope check, for instance) sees the same request the REST API would. Each operation gets its own request object.
- Denials are
403. A Payload access denial used to surface as500. These routes, and the built-in/api/puck/*endpoints, return the status Payload intended (403,404,409), so client error handling and monitoring can tell a denial from an outage.
Emergency opt-out
Setting dangerouslyDisableCollectionAccessControl: true restores the old, vulnerable behaviour and logs a warning once per factory. It exists only as a rollback while you wire up toPayloadUser. With it set, your canX hooks are the only authorization in front of read, create, update, publish, delete and version restore.
Root props mapping
Values edited in Puck's root fields are copied to Payload fields on save, and back into the editor on load, so they live in their own columns instead of only inside puckData. The defaults cover title, slug, the meta.* SEO fields, pageLayout and conversionTracking.*. In particular, metaTitle maps to meta.title and metaDescription to meta.description; you don't need custom mappings for these.
Custom mappings replace defaults with the same from value. Their to paths must exist on your pages collection. If you use custom routes alongside the admin editor, set the same rootPropsMapping on createPuckPlugin and the route factories: the admin editor loads with the plugin's mappings, while custom routes save with their own.
import { createPuckPlugin } from '@delmaredigital/payload-puck/plugin'
import { createPuckApiRoutesWithId } from '@delmaredigital/payload-puck/api'
// Optional example using a field the generated pages collection defines.
const rootPropsMapping = [
{ from: 'pageLayout', to: 'pageLayout' },
]
// In payload.config.ts:
createPuckPlugin({ rootPropsMapping })
// In your custom route, with config and auth as shown above.
// Both rootPropsMapping values must match for load/save parity.
export const { GET, PATCH, DELETE } = createPuckApiRoutesWithId({
collection: 'pages',
payloadConfig: config,
auth,
rootPropsMapping,
})
