---
title: "Complete Guide: Fully Replace a Payload List View (v3)"
slug: "replace-payload-list-view"
published: "2026-09-04"
updated: "2026-09-14"
validated: "2026-09-13"
categories:
  - "Payload"
tags:
  - "Payload List View"
  - "custom list view"
  - "payload.find"
  - "@payloadcms/ui"
  - "admin.components.views.list"
  - "batched lookups"
  - "select pitfall"
  - "Gutter padding"
  - "Table columns"
  - "Payload v3"
  - "full replace list view"
llm-intent: "reference"
audience-level: "advanced"
framework-versions:
  - "payload v3"
  - "@payloadcms/ui"
  - "typescript"
  - "react"
  - "pnpm"
status: "stable"
llm-purpose: "Payload List View — learn to replace the default table with a custom admin list view. Follow step-by-step code examples, payload.find patterns, batched…"
llm-prereqs:
  - "Access to Payload v3"
  - "Access to @payloadcms/ui"
  - "Access to TypeScript"
  - "Access to React"
  - "Access to pnpm"
llm-outputs:
  - "Completed outcome: Payload List View — learn to replace the default table with a custom admin list view. Follow step-by-step code examples, payload.find patterns, batched…"
---

**Summary Triples**
- (List view customization, hasTwoApproaches, slot components or full replace (admin.components.views.list.Component))
- (Slot components, preserve, native search, filter builder, sort, bulk actions, column picker)
- (Full replace, requires, manual implementation of search/filter UI, pagination, and bulk actions if needed)
- (When to full-replace, recommendedIf, default table cannot express multi-line rows, computed groupings, resolved cross-collection display, or non-tabular layouts)
- (Resolve cross-collection data, use, payload.find with batched lookups to resolve referenced document titles/names)
- (Select field pitfall, warning, select-like fields may not include full referenced document—resolve separately rather than trusting stored value)
- (Example implementation files, locatedAt, src/payload/admin-components/approval/ApprovalRequestsListView.tsx and related CSS and collection file paths)
- (UI components, recommend, use @payloadcms/ui and native Table when fitting; custom layout for task-list-style UI)
- (Pagination, note, must be implemented by developer when doing a full replace)

### {GOAL}
Payload List View — learn to replace the default table with a custom admin list view. Follow step-by-step code examples, payload.find patterns, batched…

### {PREREQS}
- Access to Payload v3
- Access to @payloadcms/ui
- Access to TypeScript
- Access to React
- Access to pnpm

### {STEPS}
1. Decide full replace or decorate
2. Register the custom component
3. Create component skeleton
4. Fetch your own data
5. Render with @payloadcms/ui
6. Avoid Gutter padding pitfall
7. Resolve cross-collection data in batches
8. Regenerate the import map
9. Verify and test changes

<!-- llm:goal="Payload List View — learn to replace the default table with a custom admin list view. Follow step-by-step code examples, payload.find patterns, batched…" -->
<!-- llm:prereq="Access to Payload v3" -->
<!-- llm:prereq="Access to @payloadcms/ui" -->
<!-- llm:prereq="Access to TypeScript" -->
<!-- llm:prereq="Access to React" -->
<!-- llm:prereq="Access to pnpm" -->
<!-- llm:output="Completed outcome: Payload List View — learn to replace the default table with a custom admin list view. Follow step-by-step code examples, payload.find patterns, batched…" -->

# Complete Guide: Fully Replace a Payload List View (v3)
> Payload List View — learn to replace the default table with a custom admin list view. Follow step-by-step code examples, payload.find patterns, batched…
Matija Žiberna · 2026-09-04

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.

Worked example files:
- `src/payload/admin-components/approval/ApprovalRequestsListView.tsx`
- `src/payload/admin-components/approval/ApprovalRequestsListView.module.css`
- `src/payload/collections/governance/workflows/ApprovalRequests/index.ts`

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.ts
export const YourCollection: 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.tsx
import type { ListViewServerProps } from "payload";

export async function YourListView(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 empty
select: { stepResults: true }

// RIGHT — every needed sub-field named explicitly
select: {
  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:

```tsx
import { Button, ExternalLinkIcon, Gutter, Pill, Table } from "@payloadcms/ui";
```

Components used in the worked example, and why:

- **`Gutter`** — wraps your whole view for the standard horizontal admin
  margins. `<Gutter className={styles.page}>{children}</Gutter>`.
- **`Pill`** — status/state badges. `pillStyle` accepts
  `'error' | 'light' | 'light-gray' | 'success' | 'warning' | ...`.
  `<Pill pillStyle="warning" size="small">Overdue 4d</Pill>`.
- **`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:

```tsx
import type { Column } from "payload";

type PresentationColumn = {
  accessor: string;
  active: boolean;
  Heading: React.ReactNode;
  renderedCells: React.ReactNode[]; // one entry per row, same order as `data`
};

function buildColumns(rows: YourRow[]): PresentationColumn[] {
  return [
    {
      accessor: "title",
      active: true,
      Heading: "Title",
      renderedCells: rows.map((row) => <TitleCell key={row.id} row={row} />),
    },
    {
      accessor: "status",
      active: true,
      Heading: "Status",
      renderedCells: rows.map((row) => <Pill key={row.id} pillStyle="success">{row.status}</Pill>),
    },
    // ...more columns
  ];
}

// Render:
<Table columns={buildColumns(rows) as unknown as Column[]} data={rows} />
```

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:

```scss
@layer payload-default {
  .gutter--left { padding-left: var(--gutter-h); }
  .gutter--right { padding-right: var(--gutter-h); }
}
```

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: 24px 0;
}
```

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

```css
.page {
  padding-top: 24px;
  padding-bottom: 48px;
}
```

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:

1. 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).
2. `Promise.all` one `find({ where: { id: { in: [...ids] } } })` per group.
3. 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).
4. 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
async function loadTargetTitles(
  payload: Payload,
  rows: YourRow[],
): Promise<Map<string, string>> {
  const groups = new Map<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: new Set() };
    group.ids.add(row.targetDocumentId);
    groups.set(key, group);
  }

  const titles = new Map<string, string>();
  await Promise.all(
    [...groups.values()].map(async (group) => {
      try {
        const result = await payload.find({
          collection: group.collection as any,
          depth: 0,
          limit: group.ids.size,
          locale: group.locale as any,
          overrideAccess: true, // see note below
          select: { title: true } as any,
          where: { id: { in: [...group.ids] } },
        });
        for (const doc of result.docs) {
          if (typeof (doc as any).title === "string") {
            titles.set(`${group.collection}:${doc.id}`, (doc as any).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

1. `npx tsc --noEmit` — the `Table`/`Column` cast and any `select`-shape
   typing issues will show up here first.
2. `npx eslint <your file>` — this repo forbids unused vars/imports and a
   few other things that are easy to leave behind after refactors.
3. 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).
4. 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`/`margin` **shorthand** 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.

## LLM Response Snippet
```json
{
  "goal": "Payload List View — learn to replace the default table with a custom admin list view. Follow step-by-step code examples, payload.find patterns, batched…",
  "responses": [
    {
      "question": "What does the article \"Complete Guide: Fully Replace a Payload List View (v3)\" cover?",
      "answer": "Payload List View — learn to replace the default table with a custom admin list view. Follow step-by-step code examples, payload.find patterns, batched…"
    }
  ]
}
```