If you need to show something in a Payload CMS admin form that isn't really "data" — a live computed preview, a warning banner, an action panel, a read-only summary pulled from another collection — the ui field type is the tool for it. Unlike every other field type, type: "ui" stores nothing in the database. It exists purely as a mount point in the form schema where you drop in your own React component, and from there you have the full run of Payload's client-side form state and its native @payloadcms/ui component library to build with.
I recently needed exactly this: an admin editing a permission rule needed to see, live, which actual users that rule would apply to — before saving, while still adjusting the department/role/user selectors. That's not a field with a value. It's a computed view over other fields plus a database query. This guide walks through that real implementation, then gives you the complete reference of every ui field option so you can build your own.
The use case: a live "who does this affect" preview
I was working on a governance system inside a multi-tenant Payload 3 app — a collection-access-rules collection where each document defines an access rule: who it targets (departments, sub-departments, roles, named users) and what it grants or denies (read, create, update, delete, and so on). The targeting logic already existed at runtime: a function called governanceRuleMatches decides, for a given user, whether a given rule applies to them.
The problem was that an admin building a new rule had no way to see who it actually affected until after saving it and testing it against a real login. Selecting "Department: Marketing, Role: Editor" is abstract. Seeing "this currently matches 6 people: alice@…, bob@…" is concrete, and it catches mistakes — an empty selector that "matches broadly" instead of narrowly, a role that doesn't have anyone assigned to it yet, a department typo — before the rule is ever saved.
So the requirement was: as the admin changes the department/role/sub-department/user fields in the form, query the real users collection, run the exact same matching function the runtime uses, and render the result — live, without saving, without a page reload.
That's not something an array field, a relationship field, or a group field can do. None of them execute arbitrary logic against other collections and render a custom result. That's precisely what the ui field type is for.
Why ui and not something else
Before reaching for ui, it's worth being clear on what makes it different from Payload's other customization points, because there's real overlap and it's easy to reach for the wrong one.
Approach
What it's for
Stores data?
When to use it instead
type: "ui" field
Mount an arbitrary custom component anywhere in the Edit form, with access to full form state
No
You need a computed view, a live preview, an action panel, or any UI that depends on other fields but has no value of its own
Custom Field component on a real field
Replace how an existing field's value is rendered and edited
Replace or add an entire route in the admin panel (a list view, a whole edit view, a dashboard page)
N/A — page-level, not field-level
You need something bigger than one field's worth of space, or something that isn't attached to a single document at all
virtual field
Compute and store a value derived from other fields or relationships, evaluated server-side, saved as real field data
Yes (derived, but persisted)
You want the computed value to exist in the database and API response — e.g. for filtering, sorting, or exposing it over REST
Field-level afterRead hook
Transform a field's value when a document is read
No new storage, transforms existing data
You need to reshape a stored value for API consumers, not for a live in-form preview
The deciding question is simple: does this need to be a value in the document, or is it just something the admin needs to see while editing? If it's the latter, you want ui.
Building it: the server/client pair
Every custom Field component in Payload — ui fields included — follows a two-file convention once you're doing anything non-trivial: a Server Component that reads the field config and any request-scoped data, and a Client Component that owns the interactive part. This isn't strictly required (you can write a single client component and skip the server half), but splitting them keeps your client bundle small and gives you a clean place to do server-only work — reading payload.config, hitting the database directly, checking req.user — without shipping any of that to the browser.
That's the entire field definition. No label, no required, no defaultValue — none of that applies, because there's no value. The only thing that matters is admin.components.Field, a string in the form "<path-from-repo-root>#<exported-function-name>" that tells Payload which component to render at this position in the form. I placed this field directly after the department/sub-department/role/user selector fields in the tab, so it renders immediately below them — field order in the array is layout order in the form, exactly like any other field.
There's barely anything here, and that's the point — this preview's data doesn't exist server-side at render time. The selector the admin is building (which departments, which roles) only lives in unsaved client form state, so there's nothing to precompute on the server for this particular field. All I need from the server is path, the dot-notation location of this field within the form (e.g. matchingUsersPreview), which I forward to the client component so it can register itself correctly with Payload's form machinery.
Contrast this with a ui field that does need server data — say, a field that shows "eligible field names for this collection" computed by inspecting the sanitized Payload config. That server component would do real work: call into payload.config, filter a schema, and hand the client a plain, serializable prop. UIFieldServerProps gives you field, payload, req, user, and more for exactly that case — the point is you only pay for what you use, and here I didn't need any of it beyond path.
The client half: where @payloadcms/ui does the real work
This is where the ui field type stops being empty and becomes powerful. Because the field has no value of its own, it isn't really "a field" from the client component's point of view — it's just a React component rendered inside Payload's FormProvider. That means every hook and every component in @payloadcms/ui is available to it, the same as it would be to any other custom Field.
Three things are doing the actual work here, and they're worth calling out individually because they're the pattern you'll reuse in every ui field you build.
useFormFields reads sibling field values reactively. This is the hook that makes a ui field aware of the rest of the form. I'm not reading departments because this field owns it — I'm reading it because it's a sibling field elsewhere in the same document, and I want my component to re-render whenever an admin changes it. The selector pattern useFormFields(([fields]) => fields.someName?.value) subscribes narrowly to just that one field's value, so changing an unrelated field elsewhere in the form doesn't trigger a re-render here. If you need several sibling values, call the hook once per field rather than trying to select an object — Payload's docs are explicit that pulling a single field is the performant path, since selecting a whole slice of state causes broader re-renders every time any field in that slice changes.
A debounced fetch calls a real REST endpoint, not the Local API. A ui field's client component runs in the browser, so it can't call payload.find() directly — that's server-only. Instead I added a small custom endpoints entry to the same collection config, which the client hits over HTTP. I cover that below; the point here is that "live preview" almost always means "client component calling an endpoint," and the debounce (setTimeout plus a requestId ref to discard stale in-flight responses) exists purely so five quick relationship-field changes in a row produce one request, not five racing ones.
The render itself is built entirely from @payloadcms/ui primitives — Banner for the "select a Site first" and error states, Pill for each matched user, FieldLabel for the heading. None of this is custom-styled HTML. Payload ships its actual admin-panel component library at the flat @payloadcms/ui import — the same Button, Banner, Pill, Collapsible, FieldLabel, FieldDescription, TextInput, and ReactSelect components the built-in fields themselves are built from. I wrote about the full inventory of that package in more depth in my guide to Payload's custom admin components; the short version for this article is that a ui field is the ideal place to use them, because you have an entire empty canvas and no default rendering to fight against or override.
The endpoint it calls
For completeness, here's the shape of the endpoint side, registered directly on the collection:
// File: .../CollectionAccessRules/endpoints/preview-matching-users.tsexportconstpreviewMatchingUsersEndpoint: Endpoint = {
path: "/preview-matching-users",
method: "get",
handler: async (req) => {
// parse site/departments/subDepartments/roles/users from the query string// build a draft selector with the same toSelectorRule() the runtime uses// payload.find() every user on that Site// filter with governanceRuleMatches() — the exact predicate saved rules are evaluated with// return { matchedCount, totalSiteUsers, users, truncated }
},
};
The detail that matters most here isn't the endpoint plumbing — it's that the preview reuses the exact same matching function the runtime access-control layer evaluates saved rules with. That's a deliberate design choice worth calling out on its own: a preview that reimplements its own approximate version of the real logic is worse than no preview at all, because it can show a result that doesn't match what actually happens once the rule is saved. Building the preview as a thin UI wrapper around the real predicate function is what makes it trustworthy.
Full reference: every ui field option
Here's the complete set of configuration a ui field accepts, as of Payload 3.88.0. Most of it lives under admin, since — again — there's no "value" half of the field to configure.
Property
Type
Purpose
name
string
Required. Used as the field's key/path in the form schema — this is what path resolves to in your component, and what other fields' condition functions would reference if they needed to. Not saved anywhere.
type
"ui"
Required. Marks this as a non-data field.
label
string \| Record<string, string>
Optional label, usable by your component (or a FieldLabel you render) — Payload doesn't auto-render one for you the way it does for data fields.
custom
Record<string, any>
Server-only extension point. Use this to pass configuration into your server component without hardcoding it — see the "generalizing a field" pattern below.
admin.components.Field
PayloadComponent
The component actually rendered at this position. This is the one property that matters for almost every ui field.
admin.components.Cell
PayloadComponent
What renders in the List View table if you add this field to defaultColumns. Rare for ui fields, but available — useful for a computed status badge in a list row.
admin.components.Description / Diff / Filter
PayloadComponent
Same override points every field type gets. Filter lets a ui field participate in the List View's filter dropdown if you build one; uncommon but not disallowed.
admin.condition
(data, siblingData) => boolean
Show or hide the field based on other fields' values — runs client- and server-side. This is how you'd hide a preview panel until a prerequisite field (like site, in my case) is filled in, as an alternative to handling the empty state inside the component itself.
admin.custom
Record<string, any>
Like top-level custom, but available on both server and client — use this instead of the top-level one if your client component also needs the config value, not just the server component.
admin.position
string
Set to "sidebar" to render in the document sidebar instead of the main form body. I used this for a related "conflict preview" panel on the same collection so it stays visible while scrolling the main tabs.
admin.width
CSSProperties["width"]
Same width control every field gets inside a row layout.
admin.disableBulkEdit
boolean, default true for ui fields
Whether this field appears in the bulk-edit field picker. Since it has no value, you almost always want the default.
Control whether the field shows up as a List View column/filter/group-by option — relevant mainly if you've also given it a Cell component.
Passing configuration through custom, not hardcoded imports
One mistake I made on the first pass of a related component — a segmented Inherit/Allow/Deny permissions matrix built the same way, as a custom Field on a group field rather than a ui field — was hardcoding its list of actions by importing a constant from one specific collection's field file. That worked until I needed the identical matrix on a second collection with a different, shorter action list. The fix was field.custom:
The same technique applies directly to ui fields. If you're building a preview panel you expect to reuse — across collections, or in more than one place on the same collection — read its configuration from field.custom in the server component and pass it down as a prop, rather than importing collection-specific constants into a component meant to be generic. It's the same discipline as any other reusable component: configuration in, not assumptions baked in.
FAQ
Can a ui field have validation or be required?
No. Since it holds no value, required and validate don't apply to it — there's nothing to validate. Any validation your preview logic implies (like "this rule needs at least one matching user") has to live on a real field elsewhere, or in a beforeValidate collection hook.
Does a ui field show up in the API response or generated TypeScript types?
No. It's excluded from the document shape entirely — it's a schema/form-only construct. If you need the computed value available over the API (not just visible in the admin), you want a virtual field instead, which does get persisted and returned.
Can I use the Local API (payload.find) directly inside a ui field's client component?
Not from the browser — the Local API is server-only. Your client component has to call a REST endpoint (either a built-in Payload collection endpoint or a custom one you register via endpoints on the collection config) or a Next.js Route Handler. Server Components rendered as part of the admin panel can use the Local API directly, which is why splitting server/client and doing as much as possible on the server side is worth it when the data doesn't depend on live, unsaved form state.
When should I use admin.position: "sidebar" versus leaving it in the main form?
Sidebar is for anything that should stay visible regardless of which tab or section the admin has scrolled to — status indicators, quick actions, short always-relevant summaries. A live preview tied to specific fields elsewhere in the main form (like mine, which depends on the department/role selectors sitting right above it) usually reads better placed directly beneath those fields, so the relationship between "what you changed" and "what changed as a result" is spatially obvious.
Do I need the server/client split, or can I write one client-only component?
You can skip the server half and point admin.components.Field straight at a "use client" component if it needs nothing server-side. I use the split habitually because it's cheap and keeps the door open — the moment a ui field needs anything from payload, req, or the sanitized config, you'll want that logic on the server rather than fetched over an extra network round trip.
Wrapping up
The ui field type is Payload's answer to "I need something in this form that isn't a value" — a live computed preview, a status panel, an action row, anything that reads from other fields or other collections but has nothing of its own to save. The field config itself is close to empty; almost everything happens in the component you point admin.components.Field at, and because that component renders inside Payload's own FormProvider, you get full access to useFormFields, useField, and the entire native @payloadcms/ui component set — the same building blocks Payload's own fields are made of. Reuse the runtime logic your preview is standing in for (don't reimplement an approximate version of it), read configuration through custom instead of hardcoding it if you expect to reuse the component, and reach for virtual fields instead the moment you need the computed value to actually persist.
Let me know in the comments if you have questions, and subscribe for more practical Payload CMS guides.