---
title: "Payload CMS ui Field Type: Build Live Previews (Guide)"
slug: "payload-cms-ui-field-live-preview"
published: "2026-09-02"
updated: "2026-09-14"
validated: "2026-09-13"
categories:
  - "Payload"
tags:
  - "Payload CMS ui field"
  - "Payload CMS"
  - "ui field type"
  - "live preview"
  - "admin UI"
  - "useFormFields"
  - "@payloadcms/ui"
  - "custom Field component"
  - "debounced endpoint"
  - "preview-matching-users"
  - "virtual field"
  - "governanceRuleMatches"
llm-intent: "reference"
audience-level: "advanced"
framework-versions:
  - "payload cms"
  - "@payloadcms/ui"
  - "react"
  - "typescript"
  - "next.js"
status: "stable"
llm-purpose: "Payload CMS ui field: create live admin previews that store nothing, mount React components, call debounced endpoints, and reuse runtime logic with code…"
llm-prereqs:
  - "Access to Payload CMS"
  - "Access to @payloadcms/ui"
  - "Access to React"
  - "Access to TypeScript"
  - "Access to Next.js"
llm-outputs:
  - "Completed outcome: Payload CMS ui field: create live admin previews that store nothing, mount React components, call debounced endpoints, and reuse runtime logic with code…"
---

**Summary Triples**
- (ui field type, stores, nothing in the database; it's a mount point for custom React components in the admin form)
- (ui field component, can access, live, unsaved form values via useFormFields / useForm from @payloadcms/ui)
- (live preview, should fetch, computed query results from a server endpoint (debounced) rather than reading saved DB state)
- (server endpoint, can reuse, the project's runtime logic (e.g., governanceRuleMatches) to compute matching users)
- (client preview, should use, a debounced fetch pattern to avoid excessive queries while the admin edits fields)
- (previewing pre-save matches, requires, sending current unsaved form values to the endpoint so it can compute matches against those values)
- (security, requires, protecting the preview endpoint (admin-only, token, or same-origin checks) because it can run DB queries)
- (ui field, is ideal for, read-only summaries, computed previews, action panels, and other UI that should not persist to the DB)

### {GOAL}
Payload CMS ui field: create live admin previews that store nothing, mount React components, call debounced endpoints, and reuse runtime logic with code…

### {PREREQS}
- Access to Payload CMS
- Access to @payloadcms/ui
- Access to React
- Access to TypeScript
- Access to Next.js

### {STEPS}
1. Define the ui field in collection
2. Create the server component
3. Build the client component
4. Debounce and call an endpoint
5. Reuse runtime matching logic
6. Register the endpoint
7. Test and generalize via custom

<!-- llm:goal="Payload CMS ui field: create live admin previews that store nothing, mount React components, call debounced endpoints, and reuse runtime logic with code…" -->
<!-- llm:prereq="Access to Payload CMS" -->
<!-- llm:prereq="Access to @payloadcms/ui" -->
<!-- llm:prereq="Access to React" -->
<!-- llm:prereq="Access to TypeScript" -->
<!-- llm:prereq="Access to Next.js" -->
<!-- llm:output="Completed outcome: Payload CMS ui field: create live admin previews that store nothing, mount React components, call debounced endpoints, and reuse runtime logic with code…" -->

# Payload CMS ui Field Type: Build Live Previews (Guide)
> Payload CMS ui field: create live admin previews that store nothing, mount React components, call debounced endpoints, and reuse runtime logic with code…
Matija Žiberna · 2026-09-02

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.

<table>
<thead>
<tr><th>Approach</th><th>What it's for</th><th>Stores data?</th><th>When to use it instead</th></tr>
</thead>
<tbody>
<tr><td><code>type: "ui"</code> field</td><td>Mount an arbitrary custom component anywhere in the Edit form, with access to full form state</td><td>No</td><td>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</td></tr>
<tr><td>Custom <code>Field</code> component on a real field</td><td>Replace how an existing field's value is rendered and edited</td><td>Yes (the field's own value)</td><td>You want a better input experience for data that's actually being saved — see my <a href="/blog/payload-cms-custom-admin-ui-components-guide">guide to building custom Field components</a> for the segmented-toggle-matrix example</td></tr>
<tr><td>Custom admin <strong>view</strong> (<code>admin.components.views</code>)</td><td>Replace or add an entire route in the admin panel (a list view, a whole edit view, a dashboard page)</td><td>N/A — page-level, not field-level</td><td>You need something bigger than one field's worth of space, or something that isn't attached to a single document at all</td></tr>
<tr><td><code>virtual</code> field</td><td>Compute and store a value derived from other fields or relationships, evaluated server-side, saved as real field data</td><td>Yes (derived, but persisted)</td><td>You want the computed value to exist in the database and API response — e.g. for filtering, sorting, or exposing it over REST</td></tr>
<tr><td>Field-level <code>afterRead</code> hook</td><td>Transform a field's value when a document is read</td><td>No new storage, transforms existing data</td><td>You need to reshape a stored value for API consumers, not for a live in-form preview</td></tr>
</tbody>
</table>

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.

### Wiring the field into the collection config

```ts
// File: src/payload/collections/governance/access/CollectionAccessRules/fields.ts
{
  name: "matchingUsersPreview",
  type: "ui",
  admin: {
    components: {
      Field:
        "/src/payload/admin-components/matching-users-preview/MatchingUsersPreviewField#MatchingUsersPreviewField",
    },
  },
},
```

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.

### The server half

```tsx
// File: src/payload/admin-components/matching-users-preview/MatchingUsersPreviewField.tsx
import type { UIFieldServerProps } from "payload";

import { MatchingUsersPreviewFieldClient } from "./MatchingUsersPreviewFieldClient";

export async function MatchingUsersPreviewField({
  path,
}: UIFieldServerProps) {
  return <MatchingUsersPreviewFieldClient path={path} />;
}
```

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.

```tsx
// File: src/payload/admin-components/matching-users-preview/MatchingUsersPreviewFieldClient.tsx
"use client";

import { useEffect, useRef, useState } from "react";
import { Banner, FieldLabel, Pill, useFormFields } from "@payloadcms/ui";

export function MatchingUsersPreviewFieldClient({ path }: { path: string }) {
  const site = useFormFields(([fields]) => fields.site?.value);
  const departments = useFormFields(([fields]) => fields.departments?.value);
  const subDepartments = useFormFields(([fields]) => fields.subDepartments?.value);
  const roles = useFormFields(([fields]) => fields.roles?.value);
  const users = useFormFields(([fields]) => fields.users?.value);

  const siteId = typeof site === "string" || typeof site === "number" ? site : null;
  const [fetchState, setFetchState] = useState(null);
  const requestId = useRef(0);

  useEffect(() => {
    if (siteId == null) {
      requestId.current += 1;
      return;
    }
    const currentRequest = ++requestId.current;
    const timer = setTimeout(async () => {
      setFetchState({ kind: "loading" });
      const params = new URLSearchParams({ site: String(siteId) });
      // ...append departments/subDepartments/roles/users as CSV params
      const res = await fetch(
        `/api/collection-access-rules/preview-matching-users?${params}`,
        { credentials: "include" },
      );
      const data = await res.json();
      if (currentRequest === requestId.current) {
        setFetchState({ kind: "ready", data });
      }
    }, 300);
    return () => clearTimeout(timer);
  }, [siteId, departments, subDepartments, roles, users]);

  // ...render Banner / loading text / Pill list based on fetchState
}
```

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 <a href="/blog/payload-cms-custom-admin-ui-components-guide">guide to Payload's custom admin components</a>; 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:

```ts
// File: src/payload/collections/governance/access/CollectionAccessRules/index.ts
export const CollectionAccessRules: CollectionConfig = {
  slug: "collection-access-rules",
  endpoints: [previewMatchingUsersEndpoint],
  // ...
};
```

```ts
// File: .../CollectionAccessRules/endpoints/preview-matching-users.ts
export const previewMatchingUsersEndpoint: 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.

<table>
<thead>
<tr><th>Property</th><th>Type</th><th>Purpose</th></tr>
</thead>
<tbody>
<tr><td><code>name</code></td><td><code>string</code></td><td>Required. Used as the field's key/path in the form schema — this is what <code>path</code> resolves to in your component, and what other fields' <code>condition</code> functions would reference if they needed to. Not saved anywhere.</td></tr>
<tr><td><code>type</code></td><td><code>"ui"</code></td><td>Required. Marks this as a non-data field.</td></tr>
<tr><td><code>label</code></td><td><code>string \| Record&lt;string, string&gt;</code></td><td>Optional label, usable by your component (or a <code>FieldLabel</code> you render) — Payload doesn't auto-render one for you the way it does for data fields.</td></tr>
<tr><td><code>custom</code></td><td><code>Record&lt;string, any&gt;</code></td><td>Server-only extension point. Use this to pass configuration into your server component without hardcoding it — see the "generalizing a field" pattern below.</td></tr>
<tr><td><code>admin.components.Field</code></td><td><code>PayloadComponent</code></td><td>The component actually rendered at this position. This is the one property that matters for almost every <code>ui</code> field.</td></tr>
<tr><td><code>admin.components.Cell</code></td><td><code>PayloadComponent</code></td><td>What renders in the List View table if you add this field to <code>defaultColumns</code>. Rare for <code>ui</code> fields, but available — useful for a computed status badge in a list row.</td></tr>
<tr><td><code>admin.components.Description</code> / <code>Diff</code> / <code>Filter</code></td><td><code>PayloadComponent</code></td><td>Same override points every field type gets. <code>Filter</code> lets a <code>ui</code> field participate in the List View's filter dropdown if you build one; uncommon but not disallowed.</td></tr>
<tr><td><code>admin.condition</code></td><td><code>(data, siblingData) =&gt; boolean</code></td><td>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 <code>site</code>, in my case) is filled in, as an alternative to handling the empty state inside the component itself.</td></tr>
<tr><td><code>admin.custom</code></td><td><code>Record&lt;string, any&gt;</code></td><td>Like top-level <code>custom</code>, but available on both server <em>and</em> client — use this instead of the top-level one if your client component also needs the config value, not just the server component.</td></tr>
<tr><td><code>admin.position</code></td><td><code>string</code></td><td>Set to <code>"sidebar"</code> 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.</td></tr>
<tr><td><code>admin.width</code></td><td><code>CSSProperties["width"]</code></td><td>Same width control every field gets inside a <code>row</code> layout.</td></tr>
<tr><td><code>admin.disableBulkEdit</code></td><td><code>boolean</code>, default <code>true</code> for <code>ui</code> fields</td><td>Whether this field appears in the bulk-edit field picker. Since it has no value, you almost always want the default.</td></tr>
<tr><td><code>admin.disableListColumn</code> / <code>disableListFilter</code> / <code>disableGroupBy</code></td><td><code>boolean</code></td><td>Control whether the field shows up as a List View column/filter/group-by option — relevant mainly if you've also given it a <code>Cell</code> component.</td></tr>
</tbody>
</table>

### 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`:

```ts
// File: .../FieldAccessRules/fields/permissions.ts
{
  name: "permissions",
  type: "group",
  admin: {
    components: { Field: ".../PermissionsMatrixField#PermissionsMatrixField" },
  },
  custom: { actions: ["read", "edit"] },
  fields: [ /* ... */ ],
}
```

```tsx
// File: .../PermissionsMatrixField.tsx (server)
export async function PermissionsMatrixField({ field, path }: GroupFieldServerProps) {
  const actions = (field.custom as { actions?: string[] })?.actions ?? [];
  return <PermissionsMatrixFieldClient actions={actions} path={path} />;
}
```

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.

Thanks,
Matija

## LLM Response Snippet
```json
{
  "goal": "Payload CMS ui field: create live admin previews that store nothing, mount React components, call debounced endpoints, and reuse runtime logic with code…",
  "responses": [
    {
      "question": "What does the article \"Payload CMS ui Field Type: Build Live Previews (Guide)\" cover?",
      "answer": "Payload CMS ui field: create live admin previews that store nothing, mount React components, call debounced endpoints, and reuse runtime logic with code…"
    }
  ]
}
```