---
title: "Override Payload CMS Group Field with Reusable Component"
slug: "payload-cms-custom-group-field-reusable-component"
published: "2026-09-03"
updated: "2026-09-14"
validated: "2026-09-14"
categories:
  - "Payload"
tags:
  - "Payload CMS custom field"
  - "field.custom"
  - "admin.components.Field"
  - "useField"
  - "custom admin component"
  - "group field override"
  - "@payloadcms/ui"
  - "PermissionsMatrixField"
  - "server client split"
  - "Rules of Hooks"
  - "access control permissions matrix"
llm-intent: "reference"
audience-level: "advanced"
framework-versions:
  - "payload cms"
  - "@payloadcms/ui"
  - "react"
  - "typescript"
  - "next.js"
status: "stable"
llm-purpose: "Payload CMS group field: replace default dropdowns with a reusable admin Field using field.custom and useField — improve editor UX and reuse across…"
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 group field: replace default dropdowns with a reusable admin Field using field.custom and useField — improve editor UX and reuse across…"
---

**Summary Triples**
- (Payload group field, can be replaced with, admin.components.Field without changing underlying schema or validation)
- (Custom admin field pattern, splits into, a server component (serializable props) and a "use client" component (runtime useField bindings))
- (Server component, should forward, only serializable props derived from field config (e.g., action list))
- (Client component, must own, all interactive useField() hooks and UI state)
- (Per-collection configuration, should be passed via, field.custom to keep the component reusable across collections)
- (Reused component, should read, action list from props instead of importing collection-specific constants)
- (Rows rendered from a list, must have, a dedicated subcomponent for each row so useField() isn't called inside a .map() in the parent)
- (Rules of Hooks, imply, no conditional or looped useField() calls in the parent component)
- (Practical outcome, example, seven-dropdown permission UI replaced by a segmented Inherit/Allow/Deny toggle for seven rows)
- (Compatibility, verified on, Payload 3.88 admin panel)

### {GOAL}
Payload CMS group field: replace default dropdowns with a reusable admin Field using field.custom and useField — improve editor UX and reuse across…

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

### {STEPS}
1. Assess default group field limitations
2. Create the server Field component
3. Build the client-side matrix component
4. Extract per-collection actions into field.custom
5. Give each row its own component
6. Style with Payload theme variables
7. Wire the component into the schema

<!-- llm:goal="Payload CMS group field: replace default dropdowns with a reusable admin Field using field.custom and useField — improve editor UX and reuse across…" -->
<!-- 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 group field: replace default dropdowns with a reusable admin Field using field.custom and useField — improve editor UX and reuse across…" -->

# Override Payload CMS Group Field with Reusable Component
> Payload CMS group field: replace default dropdowns with a reusable admin Field using field.custom and useField — improve editor UX and reuse across…
Matija Žiberna · 2026-09-03

Payload CMS lets you replace a group field's default rendering with a fully custom component through `admin.components.Field`, and the underlying schema stays exactly as Payload validates and stores it. The pattern splits into two pieces: a server component that reads the field config and forwards only serializable props, and a `"use client"` component that owns every interactive `useField()` binding. To keep that client component reusable across more than one collection, pass the per-collection configuration through `field.custom` on the field definition, so the component reads its action list as a prop, with no import tying it to one specific collection's file. If the component renders one row per item in that list, give each row its own subcomponent, since `useField()` can't run inside a `.map()` in the parent without becoming a hook inside a loop.

I recently built exactly this for a client's Payload 3.88 admin panel: a seven-row segmented Inherit/Allow/Deny toggle that replaced seven separate dropdowns, then extended the same component to a second collection the same afternoon by moving its action list into `field.custom`.

## The problem with a default-rendered group field

The client project has a governance and access-control system: editors set permissions like read, create, update, delete, submit, publish, and translate on a rule, and each one resolves to `inherit`, `allow`, or `deny`. The schema for that was a Payload `group` field with seven `select` sub-fields, each offering the same three options. That schema was correct from the start — validation worked, defaults worked, the data shape was exactly what the access-control logic downstream expected.

The rendering was the problem. Payload's default admin UI for a `group` field with seven `select` sub-fields is seven separate dropdown menus, stacked in declaration order. Setting a rule meant opening each dropdown individually. Reading a rule back meant scanning seven dropdown labels one at a time, with no way to take in the whole decision at a glance. That's exactly how the default group renderer handles an arbitrary set of sub-fields. For a small, fixed set of options repeated across every row, the result is a mismatch with what an editor actually needs to see.

The goal was a compact matrix: one row per action, a three-way segmented toggle per row, built from `@payloadcms/ui` primitives so it looks like a native part of the Payload admin panel.

## The server/client split for a custom Field component

Payload's custom field components come in a server half and a client half. The server component receives the field's config directly — `field`, `path`, `readOnly` — and its job is to pull out whatever's needed and hand it to a client component as plain, serializable props. All the interactive work, including every `useField()` call, has to happen in a `"use client"` component, since `useField()` is a React hook tied to the admin panel's client-side form state.

```tsx
// File: src/payload/admin-components/permissions-matrix/PermissionsMatrixField.tsx
import type { GroupFieldServerProps } from "payload";

import { PermissionsMatrixFieldClient } from "./PermissionsMatrixFieldClient";

export async function PermissionsMatrixField({
  field,
  path,
  readOnly,
}: GroupFieldServerProps) {
  const description =
    typeof field.admin?.description === "string"
      ? field.admin.description
      : undefined;

  const label = typeof field.label === "string" ? field.label : undefined;

  const custom = field.custom as
    | { actions?: unknown; note?: unknown }
    | undefined;
  const actions = Array.isArray(custom?.actions)
    ? custom.actions.filter(
        (action): action is string => typeof action === "string",
      )
    : [];
  const note = typeof custom?.note === "string" ? custom.note : undefined;

  return (
    <PermissionsMatrixFieldClient
      actions={actions}
      description={description}
      label={label}
      note={note}
      path={path}
      readOnly={readOnly}
    />
  );
}
```

Nothing in this component talks to the database, and it doesn't need to. There's no lookup to precompute — the action list is fixed per collection, so the whole job is narrowing `field.label`, `field.admin?.description`, and `field.custom` down to plain strings and arrays, then passing them along. That last part, reading `actions` out of `field.custom`, is what makes the whole pattern reusable — more on that below.

If you want the general reference for what's available in `@payloadcms/ui` beyond this — `useField`, `ReactSelect`, `Popup`, admin hooks, and the rest — <a href="https://www.buildwithmatija.com/blog/payload-cms-custom-admin-ui-components-guide">this guide to building custom admin fields and views</a> covers that ground. This article stays narrow: one specific override pattern for a group field, and how to make it work across collections.

## Passing per-collection config through `field.custom`

The first version of this component imported its action list and decision type directly from the collection's own `fields.ts` file, which meant it was hardwired to that one collection's exact seven actions. That was a reasonable place to start — the component only had one caller.

A second collection in the same project needed the same segmented-toggle UI for a different, shorter list: just `read` and `edit`. Duplicating the component for a two-action list would have meant two components to maintain for one UI pattern. The fix was moving the action list out of the client component's imports and into `field.custom` on each field's config, then reading it server-side:

```ts
{
  name: "permissions",
  type: "group",
  label: "Permissions Matrix",
  custom: {
    actions: permissionFields, // e.g. ["read", "create", "update", "delete", "submit", "publish", "translate"]
  },
  admin: {
    components: {
      Field: "/src/payload/admin-components/permissions-matrix/PermissionsMatrixField#PermissionsMatrixField",
    },
  },
  fields: [ /* the seven select sub-fields, unchanged */ ],
}
```

`field.custom` is an arbitrary metadata bag Payload attaches to a field's config and makes available to that field's components. It's the mechanism for getting a value across the server/client boundary without a client component importing from a specific collection's file — the client component just receives `actions` as a prop and renders whatever it's given. The same `PermissionsMatrixField` component now backs both collections: one passes a seven-item list, the other passes two.

## Why each row needs its own component

The client component renders one row per action. The natural first instinct is a `.map()` inside the component, calling `useField()` once per row. That breaks React's Rules of Hooks — the number of hook calls a component makes has to stay identical between renders, and mapping over a dynamic array inside one component body doesn't guarantee that.

The fix is giving each row its own component, so each one calls `useField()` exactly once, for its own dot-path:

```tsx
// File: src/payload/admin-components/permissions-matrix/PermissionsMatrixFieldClient.tsx
"use client";

import { Button, FieldDescription, FieldLabel, useField } from "@payloadcms/ui";
import type { StaticDescription, StaticLabel } from "payload";

import { ACCESS_DECISIONS, type AccessDecision } from "@/payload/access/types";

import styles from "./PermissionsMatrix.module.css";

const decisionLabels: Record<AccessDecision, string> = {
  inherit: "Inherit",
  allow: "Allow",
  deny: "Deny",
};

type Props = {
  actions: readonly string[];
  description?: StaticDescription;
  label?: StaticLabel;
  note?: string;
  path: string;
  readOnly?: boolean;
};

export function PermissionsMatrixFieldClient({
  actions,
  description,
  label,
  path,
  readOnly,
}: Props) {
  return (
    <div className={styles.matrix}>
      <FieldLabel label={label} path={path} />
      {description ? (
        <FieldDescription description={description} path={path} />
      ) : null}
      <div className={styles.rows}>
        {actions.map((action) => (
          <PermissionsMatrixRow
            action={action}
            key={action}
            path={`${path}.${action}`}
            readOnly={readOnly}
          />
        ))}
      </div>
    </div>
  );
}

function PermissionsMatrixRow({
  action,
  path,
  readOnly,
}: {
  action: string;
  path: string;
  readOnly?: boolean;
}) {
  const { setValue, value } = useField<AccessDecision>({ path });

  return (
    <div className={styles.row}>
      <span className={styles.actionLabel}>{action}</span>
      <div className={styles.toggleGroup}>
        {ACCESS_DECISIONS.map((decision) => {
          const isSelected = value === decision;
          return (
            <Button
              buttonStyle={isSelected ? "primary" : "secondary"}
              className={[
                styles.decisionButton,
                styles[`decision--${decision}`],
                isSelected ? styles.selected : "",
              ]
                .filter(Boolean)
                .join(" ")}
              disabled={readOnly}
              key={decision}
              onClick={() => setValue(decision)}
              size="small"
            >
              {decisionLabels[decision]}
            </Button>
          );
        })}
      </div>
    </div>
  );
}
```

`PermissionsMatrixFieldClient` maps over `actions` to decide how many rows to render, and each `PermissionsMatrixRow` is its own component instance with its own `useField()` call at `${path}.${action}` — the dot-notation path Payload's form state expects for a nested sub-field. Clicking a button calls `setValue(decision)` directly on that row's field. `disabled={readOnly}` on the `Button` threads the read-only state from the server component all the way down to each individual toggle.

## Theming with Payload's CSS variables

The layout and color accents live in a small CSS module, built entirely on Payload's own theme variables:

```css
/* File: src/payload/admin-components/permissions-matrix/PermissionsMatrix.module.css */
.matrix {
  display: flex;
  flex-direction: column;
  gap: calc(var(--base) / 2);
}

.rows {
  display: flex;
  flex-direction: column;
  gap: 2px;
  border: 1px solid var(--theme-elevation-150);
  border-radius: var(--style-radius-m);
  overflow: hidden;
}

.row {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: calc(var(--base) / 2);
  padding: calc(var(--base) / 4) calc(var(--base) / 2);
  background: var(--theme-elevation-50);
}

.actionLabel {
  text-transform: capitalize;
  font-weight: 500;
  color: var(--theme-elevation-800);
}

.toggleGroup {
  display: flex;
  gap: 4px;
}

.decisionButton {
  min-width: 72px;
  justify-content: center;
}

.decision--allow.selected {
  background-color: var(--theme-success-500);
  border-color: var(--theme-success-500);
}

.decision--deny.selected {
  background-color: var(--theme-error-500);
  border-color: var(--theme-error-500);
}

.decision--inherit.selected {
  background-color: var(--theme-elevation-500);
  border-color: var(--theme-elevation-500);
}
```

`--theme-success-500` marks a selected "allow," `--theme-error-500` marks "deny," and `--theme-elevation-500` marks the neutral "inherit" state. The chrome — borders, row backgrounds, text — pulls from the same `--theme-elevation-*` scale Payload's own components use. Every color comes from a theme variable on Payload's own scale, so the matrix follows the admin panel's light and dark mode automatically, with no separate dark-mode styles to maintain.

## Wiring it in without touching the schema

The only edit to the collection's field definition is the `admin.components.Field` pointer and the `custom.actions` array shown earlier. The seven `select` sub-fields inside the group stay exactly as they were, including their `defaultValue: "inherit"`. That's a deliberate property of this pattern: the custom component overrides rendering, and Payload's own schema, validation, and default-value handling keep doing their job underneath it. `useField()` in each row just reads whatever value is already there — on a new document, that's the schema's default, with no extra logic needed in the component to make "inherit" show up correctly.

## From one collection to two, the same afternoon

The commit history for this feature is a clean before-and-after. The first commit created all three files in the form shown above, with one exception: the client component imported its action list and decision type directly from the first collection's `fields.ts`, with no `actions` prop yet in place. It worked, and it was scoped to exactly one collection.

About six hours later, a second collection needed the same three-way toggle for a shorter, two-item action list. A follow-up commit avoided duplicating the component: it extracted the decision type into a shared `src/payload/access/types.ts` constant, moved the action list into `field.custom.actions` as shown above, and rewired the second collection's permissions group to use the same `PermissionsMatrixField` component with its own two-action list and its own explanatory copy. The commit message states the reasoning plainly: reuse the same segmented-toggle UI for the second collection's field-level permissions group, without building and maintaining a second bespoke matrix component.

That's a fairly ordinary sequence for real projects — build the specific version first, generalize it once a second real use case shows up — and the `field.custom` pattern is what kept the generalization to a small, contained refactor. It's also worth saying plainly: there's no commit in this project's history that fixes a bug in these three files after the initial build. Anything that needed adjusting along the way went uncaptured, so this account sticks to what the history actually shows.

## Default rendering vs. a custom Field component

| Approach | When to use | Trade-off |
|---|---|---|
| Default group field rendering | The sub-fields don't share a visual relationship, or the group is edited rarely | Fast to set up, zero custom code, but scales poorly once the group represents one cohesive decision |
| Custom `Field` component, single collection | One group field needs a purpose-built UI and there's exactly one caller | Full control over the UI, but the component and its data source are coupled together |
| Custom `Field` component with `field.custom` config | The same UI pattern applies to more than one collection, with different field lists | One component to maintain across collections, at the cost of a small server-side prop-narrowing step per caller |

## FAQ

**Does overriding a field's rendering change how Payload stores the data?**
Overriding `admin.components.Field` changes only how the field renders in the admin panel. The `fields` array inside the group definition remains the actual schema Payload validates and stores against, so validation, defaults, and the stored data shape stay exactly as they were before the custom component existed.

**Why can't `useField()` run inside a `.map()`?**
React's Rules of Hooks require every component to call the same hooks, in the same order, on every render. Mapping over a dynamic array and calling `useField()` once per item inside that map violates that rule the moment the array's length can change. Giving each item its own component sidesteps the problem entirely, since each component instance calls the hook exactly once.

**How do you pass different action lists into the same shared component?**
Through `field.custom` on each field's definition. The server component reads `field.custom.actions` and forwards it as a plain prop to the client component, so the client component never needs to know which collection it's rendering for.

**Does the custom component respect `readOnly` automatically?**
Payload passes `readOnly` into the server component as part of `GroupFieldServerProps`, and from there it has to be threaded through explicitly — down to the client component's props, and down to each row's `disabled` attribute on its buttons. Once a custom component takes over rendering, `readOnly` gets accepted and passed along like any other prop.

**Do you need to handle default values inside the custom component?**
No separate logic for that lives in the component. Each `select` sub-field's `defaultValue` in the schema is what Payload's form state initializes with, and `useField()` simply reads whatever value is already in that state — including the default, on a brand-new document.

## Wrapping up

A group field's default rendering is a fine fit for a set of unrelated settings edited independently. Here, the seven sub-fields represent one decision, read and set together — the case this custom component is built for. Payload's server/client `Field` component split handles that case cleanly: a server component narrows the field config down to serializable props, a client component owns the interactive state, and `field.custom` carries whatever per-collection configuration keeps that client component from being hardwired to a single caller. The one structural rule to keep in mind is that per-row hooks need per-row components — anything else runs into the Rules of Hooks the first time the row count changes.

If you're working through a similar override on an array field, <a href="https://www.buildwithmatija.com/blog/payload-cms-custom-array-fields-table-ui">this walkthrough of a custom array-field spreadsheet UI</a> covers a separate, related set of constraints.

Let me know in the comments if you have questions, and subscribe for more practical development guides.

Thanks, Matija

---

### SEO metadata

**Title:** Override Payload CMS Group Fields With a Reusable Custom Admin Component
**Meta description:** Replace a Payload CMS group field's default dropdown rendering with a custom, reusable Field component using field.custom, useField, and the server/client split.
**Suggested slug:** payload-cms-custom-group-field-reusable-component
**Keywords:** Payload CMS, custom field component, admin.components.Field, field.custom, useField, group field, @payloadcms/ui, GroupFieldServerProps
**Last updated:** September 2026 (Payload 3.88.0, Next.js 16.3.3, React 19.2.7)

## LLM Response Snippet
```json
{
  "goal": "Payload CMS group field: replace default dropdowns with a reusable admin Field using field.custom and useField — improve editor UX and reuse across…",
  "responses": [
    {
      "question": "What does the article \"Override Payload CMS Group Field with Reusable Component\" cover?",
      "answer": "Payload CMS group field: replace default dropdowns with a reusable admin Field using field.custom and useField — improve editor UX and reuse across…"
    }
  ]
}
```