Skip to content

Customize the Payload admin

Your editors work in Payload’s own admin, with the Evolve pages and blocks in it. You change it in three ways, each in its own place:

To Add In
Change the config: collections, fields, storage a plugin packages/payload-config
Show your own UI in the admin a component frontend/payload-admin/src/admin
Make it look like your brand a logo and CSS frontend/payload-admin

The config is one module that two apps load: the admin app, and your CMS service with PayloadModule. Components are named in it by path, so only the admin app loads them.

flowchart LR
  config["packages/payload-config<br/>payload.config.ts"] --> admin["frontend/payload-admin<br/>the admin app"]
  config --> service["your CMS service<br/>PayloadModule"]
  components["frontend/payload-admin/src/admin<br/>components and CSS"] --> admin

This page assumes Payload is your CMS. See Add a CMS.

A Payload plugin is a function that takes the config and returns a new one. This one puts every Evolve collection under one heading in the sidebar:

import { evolveCms } from "@evolve-framework/payload/config";
import { buildConfig, type Config, type Plugin } from "payload";
const groupCollections: Plugin = (incoming: Config): Config => ({
...incoming,
collections: (incoming.collections ?? []).map((collection) =>
collection.slug === "users"
? collection
: { ...collection, admin: { ...collection.admin, group: "Content" } },
),
});
export default buildConfig({
// ...database adapter, editor, and secret
plugins: [...evolveCms(), groupCollections],
});
  • Put yours after evolveCms(). Payload runs the plugins in order, so yours sees the collections Evolve registers.
  • Spread evolveCms(). It returns a list of plugins. Payload collects the list before it runs any of them, so a plugin added from inside another plugin never runs.

An official Payload plugin goes in the same list. For example, keep media in S3 instead of on the admin’s disk:

pnpm --filter ./packages/payload-config add @payloadcms/storage-s3
import { s3Storage } from "@payloadcms/storage-s3";
export default buildConfig({
plugins: [
...evolveCms(),
s3Storage({
collections: { media: true },
bucket: process.env.S3_BUCKET ?? "",
config: { region: process.env.S3_REGION },
}),
],
});

Install the same version as payload. Payload refuses to start with mixed versions of its packages.

This one replaces a text field with your brand colors as swatches.

"use client";
import { FieldLabel, useField } from "@payloadcms/ui";
import type { TextFieldClientProps } from "payload";
const colors = { brand: "#0b5fff", sand: "#e9dcc9", ink: "#1b1f24" };
export function BrandColorField({ path, field }: TextFieldClientProps) {
const { value, setValue } = useField<string>({ path });
return (
<div className="field-type text">
<FieldLabel label={field?.label} path={path} required={field?.required} />
<div className="brand-colors">
{Object.entries(colors).map(([name, hex]) => (
<button
key={name}
type="button"
aria-label={name}
aria-pressed={value === name}
className="brand-colors__swatch"
style={{ background: hex }}
onClick={() => setValue(name)}
/>
))}
</div>
</div>
);
}

The field still stores a plain string, so your mapper and the GraphQL API see no difference.

Name the component by path in the field’s admin.components. A path that starts with / is relative to frontend/payload-admin.

payload: {
slug: "quote",
fields: [
{ name: "quote", type: "textarea", required: true },
{
name: "background",
type: "text",
admin: {
components: { Field: "/src/admin/brand-color-field#BrandColorField" },
},
},
],
},

This is the Payload config of a block of your own. See Add a content block.

pnpm --filter ./frontend/payload-admin generate:importmap

Payload adds the component to src/app/(payload)/admin/importMap.js. Commit that file, and run the command again whenever you add, move, or rename a component.

Option Where it shows
admin.components.Field on a field In place of the field’s input
admin.components.beforeDashboard Above the collections on the dashboard
admin.components.afterNavLinks Below the links in the sidebar
admin.components.beforeLogin Above the login form
admin.components.graphics.Logo On the login screen
admin.components.graphics.Icon In the navigation

The admin.components options without a field go at the top level of your Payload config. See Payload’s custom components for all of them.

export function Logo() {
return <img src="/brand/logo.svg" alt="Acme" width={180} height={40} />;
}
export function Icon() {
return <img src="/brand/icon.svg" alt="" width={24} height={24} />;
}

Put the two images in frontend/payload-admin/public/brand/. Then name the components in the config, and run generate:importmap again:

export default buildConfig({
admin: {
meta: {
titleSuffix: " · Acme CMS",
icons: [{ rel: "icon", url: "/brand/icon.svg" }],
},
components: {
graphics: {
Logo: "/src/admin/brand#Logo",
Icon: "/src/admin/brand#Icon",
},
},
},
// ...
});

The admin app imports custom.css after Payload’s own stylesheet. Payload’s styles sit in a cascade layer, so your rules win without !important.

:root {
--font-body: "Inter", system-ui, sans-serif;
--brand: #0b5fff;
--brand-hover: #0947c2;
--brand-text: #ffffff;
}
html[data-theme="dark"] {
--brand: #8fb0ff;
--brand-hover: #b5cbff;
--brand-text: #0b1020;
}
/* Save, Publish, Create new */
.btn--style-primary {
--bg-color: var(--brand);
--hover-bg: var(--brand-hover);
--color: var(--brand-text);
}
.brand-colors {
display: flex;
gap: calc(var(--base) * 0.4);
}
.brand-colors__swatch {
width: 2rem;
height: 2rem;
border: 1px solid var(--theme-elevation-150);
border-radius: var(--style-radius-s);
cursor: pointer;
}
.brand-colors__swatch[aria-pressed="true"] {
outline: 2px solid var(--theme-elevation-800);
outline-offset: 2px;
}
  • Both themes. Payload sets data-theme on html. Color your own components with its --theme-elevation-* variables and they follow the editor’s theme. To offer only one theme, set admin.theme: "light".
  • Your font. --font-body only names it. Load it the way any Next.js app does, for example with next/font in the layout.
  • Payload’s class names. Classes such as .btn--style-primary belong to Payload. Check your overrides when you upgrade it.