Access
Who sees which row, and why the rest never reaches the browser.
The rail each reader sees is computed on the server, for them. Rows they cannot open are not filtered out in the browser: they never leave the server in any form. There is no client-side list of what exists.
The two doors
settingsOverlay({
overlays: [
{
id: 'system',
label: 'Settings',
access: ({ overlay, req }) => canSeeModule('settings', req.user),
items: [
{ type: 'collection', slug: 'tags' },
{
type: 'link',
slug: 'billing',
label: 'Billing',
href: '/billing',
access: ({ item, req }) => req.user?.role === 'owner',
},
],
},
],
})access on the overlay is the door to the whole panel. access on an item is the door to one row. Both take { req }, so one project-level predicate can gate this panel and whatever else you already gate, with no wrapper in between.
A missing function means open. A function that throws denies, and logs.
The order of checks
The rail is built inside the admin, which Payload has already gated, for a signed-in reader. Each row passes, in order:
- the overlay's
access - the collection's or global's own
readpermission - the item's
access - the entity's own
admin.hidden, when it was declared as a function
Step four matters when a collection gates itself with a function-valued admin.hidden. The plugin lifts the predicate out at boot and applies it to the rail per reader, whether or not hideEntities is on. With hideEntities on, the entity's own admin.hidden becomes true to take it out of the nav, and the predicate is what still decides who sees the row in the panel. Payload's semantics hold: true means hidden, and a predicate that throws hides.
A view or lazy component is rendered on demand through a server function, which anybody who can call one could reach, so that path checks again from the start: req.user, then canAccessAdmin({ req }), the same first check Payload's own render-list and render-document make, then the overlay's access and the item's access.
Lists and documents are rendered by Payload's own render-list and render-document, under Payload's own rules. See below.
What reaches the browser
The full configuration is parked under config.custom, which is in Payload's serverOnlyConfigProperties and is never serialised into the client config. That covers every access function, every resolveDocID, and every component path.
What the browser receives is:
- the closed panel's own properties:
id,label,layout,className,searchable,addressable,history,mergeListHeader - a manifest per overlay: only the rows this reader may open, with labels already translated
There is no items array in the client props to leak: the panel is not built from one.
The request the provider builds
The req your access function receives while the rail is built is assembled by the plugin, not handed to it by Payload.
Its headers are the real ones, so a resolveDocID can read a cookie. Everything else is local: req.payloadAPI is 'local' and there is no response to write to. Lazy items are rendered through a different path and get Payload's own request.
The one thing to check yourself
hideEntities, when you turn it on, removes a collection from the nav and from its route, but it is not access control. Payload's own access rules still decide whether the API will serve the document, and they should: a hidden route is not a locked one. Give the collection real access rules as you would anyway.
The same holds for the two doors. They decide which rows the rail offers a reader; they do not stand between that reader and the collection's API, and a list or document opened in the panel is served under the collection's own access rules.