Internal how-to, written after replacing the default table-based List View
for the approval-requests collection with a custom, task-list-style view
(grouped sections, a native Table, resolved document titles, resolved
reviewer names, delay badges). This doc is written so an agent with zero
prior context on this codebase can follow it to do the same thing to a
different collection, in this repo or a different Payload v3 project.
These three files illustrate the concrete implementation of every step below.
1. Decide: full replace, or decorate the default?
Payload gives you two very different levers for customizing a List View:
Slot components (beforeListTable, afterListTable, beforeList,
afterList) — inject content around the default table. The table itself
stays a table (one row per document, one column per field). Cheap, safe,
keeps native search/filter/sort/bulk-actions/column-picker for free.
Full replace (admin.components.views.list.Component) — you own the
entire List route body. You can render anything (cards, grouped sections,
a different table shape), but you lose the native search box, filter
builder, column picker, and bulk-action toolbar. You must build pagination
yourself if you want it.
Only go full-replace when the built-in row-per-document table genuinely
can't express what you need (e.g. multi-line rows, computed groupings,
resolved cross-collection data as the primary display, non-tabular layout).
If you just want to reorder/reformat existing field columns, use column
config (defaultColumns) or a custom cell component instead — don't reach
for a full replace.
This guide covers the full-replace path.
2. Register the component
In the collection config, under admin.components.views.list:
ts
// src/payload/collections/.../YourCollection/index.tsexportconstYourCollection: CollectionConfig = {
slug: "your-collection",
admin: {
components: {
views: {
list: {
Component:
"/src/payload/admin-components/your-area/YourListView#YourListView",
},
// (You can also override `views.edit.default` the same way, for a// fully custom edit/detail page — same mechanics, different slot.)
},
},
},
// ...
};
The path is a Component Path string, not an import — /src/... relative
to the project root, #ExportName for a named export. Payload resolves this
through a generated import map (step 8), not directly.
3. Component skeleton and available props
The component receives ListViewServerProps (from payload). It's an
async React Server Component — you can call payload.find(...) directly
in the component body, no data-fetching hook needed.
tsx
// src/payload/admin-components/your-area/YourListView.tsximporttype { ListViewServerProps } from"payload";
exportasyncfunctionYourListView(props: ListViewServerProps) {
const { payload, user, searchParams, limit } = props;
// payload — the Payload instance (payload.find, payload.config, ...)// user — the logged-in admin user (or undefined)// searchParams — the current URL's query params (?page=2 etc.), a plain object// limit — the collection's configured admin.pagination.defaultLimit// ... fetch + render ...
}
Full prop list (from payload's ListViewServerProps type): payload,
user, i18n, locale, params, permissions, searchParams,
collectionConfig, data (Payload's own pre-fetched page — see note below),
limit, listPreferences, listSearchableFields, plus everything in
ListViewClientProps (collectionSlug, columnState, hasCreatePermission,
newDocumentURL, a pre-rendered Table node, viewType, etc.).
Note on the data prop: Payload does pre-fetch a page of documents for
you (respecting defaultColumns/search/sort from the URL) and hands it to
you as data, along with a pre-rendered Table React node built from it.
You can just render props.Table and skip fetching entirely — that's the
fastest path if the native per-field table is close enough. But if you need
extra select fields the default column set doesn't include, extra
depth, a different sort, multiple queries (e.g. "active" + "closed"
sections), or cross-collection data, fetch yourself instead of relying
on data/Table — that's what the rest of this guide does.
4. Fetch your own data
Call payload.find directly, passing user + overrideAccess: false so
the collection's normal access-control rules still apply (the List route
doesn't do this for you once you own data-fetching):
tsx
const result = await payload.find({
collection: "your-collection",
depth: 1, // 1 = populate direct relationships (e.g. a `submittedBy` user)limit: limit || 10,
overrideAccess: false,
page: Number(searchParams?.page) > 0 ? Number(searchParams.page) : 1,
select: { /* see pitfall below */ },
sort: "-createdAt",
user,
where: { /* whatever scoping you need */ },
});
// result.docs, result.totalDocs, result.page, result.totalPages,// result.hasNextPage, result.hasPrevPage
Pitfall — select on array fields with nested relationships (Postgres):
if a field is an array (or blocks) type and contains relationship
sub-fields, selecting the array with a blanket true silently drops
those relationship sub-fields on the Postgres adapter (they live in a joined
table, and the blanket selector doesn't know to join it). You must enumerate
every sub-field you need explicitly:
ts
// WRONG — assignedUsers/assignedDepartments silently come back emptyselect: { stepResults: true }
// RIGHT — every needed sub-field named explicitlyselect: {
stepResults: {
assignedDepartments: true,
assignedRole: true,
assignedUsers: true,
eligibleReviewerIds: true,
status: true,
stepName: true,
stepOrder: true,
},
}
See docs/MISTAKES_TO_AVOID.md §108 for the full writeup of this footgun —
check it before writing any select that touches an array/blocks field in
this repo.
Multiple sections in one view: if your list is really two different
result sets (e.g. "needs attention" vs "recently closed"), just run two
payload.find calls (in Promise.all) with different where/sort, and
render two sections. Only paginate the primary one; cap the secondary one at
a fixed small limit if it's secondary/contextual.
5. Render with native @payloadcms/ui components — don't hand-roll
The single biggest mistake to avoid: building your own pills, buttons, and
page-padding CSS from scratch. Payload ships a component library
(@payloadcms/ui) specifically so custom admin UI looks and behaves like
the rest of the panel. Import from the flat package root in admin code:
Button — for pagination controls, use el="anchor" + url (not
onClick) so it's a plain link in a Server Component:
<Button buttonStyle="secondary" el="anchor" url="?page=2" size="small">Next →</Button>.
ExternalLinkIcon — for "open in a new context" links (e.g. linking
out to the actual underlying document, separate from your row's primary
link).
Table — Payload's own table shell (zebra striping, cell padding,
borders, appearance="condensed" variant). See the next section — its
columns prop has an unusual, easy-to-get-right shape once you know it.
Discovery method (there's no official component doc): grep
node_modules/@payloadcms/ui/dist/elements/<Name>/types.d.ts or
index.d.ts for props, and index.scss in the same folder for the CSS
class names it ships (reuse those class names instead of inventing new
selectors when you want to match native spacing). Grep this codebase's
src/payload/admin-components/** first, though — there are ~30 existing
custom admin components already using this library; copy their import and
usage patterns before reading node_modules from scratch.
Using the native Table component with your own data (not field-driven)
Table's TypeScript type says columns: Column[] where Column requires a
real field: ClientField — but at runtime (@payloadcms/ui/dist/elements/Table/index.js)
it only ever reads accessor, active, Heading, and renderedCells. You
don't need a real field config to use it outside Payload's normal
field-driven table machinery:
The as unknown as Column[] cast is intentional and safe here — it's
bridging a real type gap (the declared type is stricter than what the
component actually consumes), not suppressing a real error. data just
needs .id on each row (used as the React key internally); the actual cell
content comes entirely from renderedCells, not from data.
6. The Gutter + custom CSS padding pitfall (read this before adding any page-level padding)
Gutter's horizontal padding rules ship inside a CSS @layer block:
Per the CSS cascade-layers spec, any un-layered rule beats a layered rule,
regardless of specificity or source order. Your CSS Modules file compiles
to plain, un-layered rules. So if you write a shorthand like this on the
element you passed as Gutter's className:
css
/* BUG: this silently zeroes out Gutter's own horizontal padding */.page {
padding: 24px0;
}
padding: 24px 0 sets padding-left/padding-right to 0 explicitly —
and that 0, even though it's "less specific," wins over Gutter's layered
padding-left: var(--gutter-h) because un-layered always beats layered.
Net effect: your page renders with no horizontal padding at all, and
it's not obvious why from looking at the CSS.
Fix: never use the padding shorthand on an element wrapped by
Gutter. Only ever set the axis you actually mean to control:
This applies to margin shorthands too if you ever fight with Gutter's
negative-margin variants (gutter--negative-left/-right).
7. Batched lookups for anything that needs cross-collection or resolved data
If your rows reference other collections (e.g. a polymorphic
targetCollection + targetDocumentId pair) or plain-text ID snapshots
that aren't real relationship fields (so depth can't auto-populate them),
resolve them with one batched query per distinct group, not one query
per row.
Pattern:
After fetching your rows, walk them and build Map<groupKey, Set<id>>
(group key = whatever varies the query shape — e.g. `${collection}::${locale}`
for polymorphic content, or nothing at all for a flat users lookup).
Promise.all one find({ where: { id: { in: [...ids] } } }) per group.
Build a single Map<string, string> (or whatever) from id → resolved
label/title, keyed identically to how you'll look it up later
(${collection}:${id} is a good key shape for polymorphic lookups).
Pass that map down as a prop to your cell-rendering components; look up
with a fallback (titles.get(key) ?? fallbackText) — a resolution
failure (deleted document, access denied) should degrade gracefully, not
throw.
tsx
asyncfunctionloadTargetTitles(payload: Payload,
rows: YourRow[],
): Promise<Map<string, string>> {
const groups = newMap<string, { collection: string; locale: string; ids: Set<string> }>();
for (const row of rows) {
const key = `${row.targetCollection}::${row.locale}`;
const group = groups.get(key) ?? { collection: row.targetCollection, locale: row.locale, ids: newSet() };
group.ids.add(row.targetDocumentId);
groups.set(key, group);
}
const titles = newMap<string, string>();
awaitPromise.all(
[...groups.values()].map(async (group) => {
try {
const result = await payload.find({
collection: group.collectionasany,
depth: 0,
limit: group.ids.size,
locale: group.localeasany,
overrideAccess: true, // see note belowselect: { title: true } asany,
where: { id: { in: [...group.ids] } },
});
for (const doc of result.docs) {
if (typeof (doc asany).title === "string") {
titles.set(`${group.collection}:${doc.id}`, (doc asany).title);
}
}
} catch {
// swallow — that row falls back to a generic label
}
}),
);
return titles;
}
On overrideAccess: true for these lookups: only do this when (a) the
value being read is minimal, low-sensitivity display metadata (a title, a
name, an email) that's already shown elsewhere in the same feature to the
same audience, and (b) the outer list is already properly access-scoped
(the row itself was only returned because the viewer is allowed to see it).
The goal is "don't let an unrelated collection-level permission gap turn
into a broken/blank UI for evidence the viewer is already entitled to see,"
not "bypass access control broadly." Document the reasoning inline — see
the comment above loadTargetTitles in the worked example.
If the IDs you're resolving are plain snapshotted text, not real
relationship fields (e.g. an array of { value: text } capturing user IDs
at some point in time, deliberately not a live relationship), the same
batched pattern applies — you're just doing a normal find against the
users (or whatever) collection with those IDs, since depth can't help
you here at all.
8. Regenerate the import map
Payload resolves Component Path strings (step 2) through a generated file,
usually src/app/(payload)/admin/importMap.js. It's regenerated
automatically on dev-server start/HMR, but after adding a new custom
component export, run it manually to make sure the entry exists before you
rely on a fresh process picking it up:
bash
pnpm generate:importmap
Never hand-edit that file. If the command says "No new imports found,
skipping writing import map," your component was already registered by a
previous run — that's fine, not an error.
9. Verify
npx tsc --noEmit — the Table/Column cast and any select-shape
typing issues will show up here first.
npx eslint <your file> — this repo forbids unused vars/imports and a
few other things that are easy to leave behind after refactors.
Actually load the page in a browser (pnpm dev, or this repo's HTTPS
dev-proxy — see dev-proxy/README.md) and check:
Horizontal + vertical padding present (see §6 if not).
Pagination works (?page=2 etc.) if you built it.
Every row's primary link and any secondary/external links resolve to
the right place.
Resolved titles/names actually show up (not just IDs) — if they don't,
re-check your select (§4 pitfall) and your batched-lookup group keys
(§7) match exactly between build and lookup time.
Light and dark admin theme (Payload's --theme-* CSS custom
properties, not raw colors — see any .module.css in
src/payload/admin-components/** for the variable names in use).
Log in as more than one user role if the collection's access.read is
scoped (submitter-only, reviewer-only, admin-sees-all, etc.) — confirm
the list actually narrows per viewer, since a full-replace view is
responsible for calling payload.find with the right user +
overrideAccess: false itself; it's easy to accidentally leave that off
and silently show everyone everything.
Recipe checklist (copy this into your task)
Confirmed full-replace is actually warranted (§1), not just a column
tweak.
Created YourListView.tsx exporting an async function taking
ListViewServerProps.
Registered it at collection.admin.components.views.list.Component
with the /src/...#ExportName path string.
Data fetched via payload.find({ user, overrideAccess: false, ... })
— not relying on ambient access.
Any select on an array/blocks field with relationship sub-fields
enumerates every sub-field explicitly (§4 pitfall / MISTAKES_TO_AVOID
§108).
All UI built from @payloadcms/ui primitives (Pill, Button,
Gutter, Table, etc.) — no hand-rolled pill/button CSS.
If using Gutter, no padding/marginshorthand on the element
you hand it as className (§6).
Any cross-collection or ID-snapshot data resolved via one batched
query per distinct group, not per row (§7).
pnpm generate:importmap run after adding the new component.
tsc --noEmit and eslint clean on the new/changed files.
Verified in-browser: padding, pagination, links, resolved labels,
both themes, more than one viewer role if access-scoped.