BuildWithMatija
  1. Home
  2. Blog
  3. Payload
  4. Override Payload CMS Group Field with Reusable Component

Override Payload CMS Group Field with Reusable Component

Replace Payload's default group field UI with a reusable admin.components.Field via field.custom and useField hooks.

3rd September 2026·Updated on:14th September 2026··
Payload
Override Payload CMS Group Field with Reusable Component

Evaluating Payload CMS Implementation Costs?

Scope design, content structure, and migration hours to estimate a realistic production timeline and hosting setup.

Try the Cost EstimatorGet a Second Opinion

📚 Comprehensive Payload CMS Guides

Detailed Payload guides with field configuration examples, custom components, and workflow optimization tips to speed up your CMS development process.

No spam. Unsubscribe anytime.

📄View markdown version
0

Frequently Asked Questions

About the author

Matija Žiberna

Matija Žiberna

Full-stack developer, co-founder

AboutResume

Self-taught full-stack developer sharing lessons from building software and startups.

I'm Matija Žiberna, a self-taught full-stack developer and co-founder passionate about building products, writing clean code, and figuring out how to turn ideas into businesses. I write about web development with Next.js, lessons from entrepreneurship, and the journey of learning by doing. My goal is to provide value through code—whether it's through tools, content, or real-world software.

Contents

  • The problem with a default-rendered group field
  • The server/client split for a custom Field component
  • Passing per-collection config through `field.custom`
  • Why each row needs its own component
  • Theming with Payload's CSS variables
  • Wiring it in without touching the schema
  • From one collection to two, the same afternoon
  • Default rendering vs. a custom Field component
  • FAQ
  • Wrapping up
  • SEO metadata
On this page:
  • The problem with a default-rendered group field
  • The server/client split for a custom Field component
  • Passing per-collection config through `field.custom`
  • Why each row needs its own component
  • Theming with Payload's CSS variables
Build with Matija logo

Build with Matija

Senior-led B2B websites, applications, content systems, and digital infrastructure. Business-first, full-stack, AI-assisted, no handoffs.

Services

  • B2B Website Development
  • CMS Architecture Review & Platform Blueprint
  • Next.js + Payload Advisory
  • AI Integration & Implementation

Resources

  • CMS Hub
  • B2B Website Strategy
  • E-commerce Hub
  • Blog
  • Case Studies

Payload CMS

  • Payload CMS Developer
  • Payload CMS Migration
  • Payload CMS Demos
  • All Payload CMS Resources

Discuss your project

Planning a rebuild, migration, application, workflow change, or platform decision? Start with the business problem and the system behind it.

Book a discovery callContact me →
© 2026Build with Matija•All rights reserved•Privacy Policy•Terms of Service
BuildWithMatija
Get In Touch

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 — this guide to building custom admin fields and views 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

ApproachWhen to useTrade-off
Default group field renderingThe sub-fields don't share a visual relationship, or the group is edited rarelyFast to set up, zero custom code, but scales poorly once the group represents one cohesive decision
Custom Field component, single collectionOne group field needs a purpose-built UI and there's exactly one callerFull control over the UI, but the component and its data source are coupled together
Custom Field component with field.custom configThe same UI pattern applies to more than one collection, with different field listsOne 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, this walkthrough of a custom array-field spreadsheet UI 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)