BuildWithMatija
  1. Home
  2. Blog
  3. Payload
  4. Complete Guide: Fully Replace a Payload List View (v3)

Complete Guide: Fully Replace a Payload List View (v3)

Step-by-step internal guide to replace the default table with a custom Payload List View using admin components…

4th September 2026·Updated on:14th September 2026··
Payload
Complete Guide: Fully Replace a Payload List View (v3)

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

  • 1. Decide: full replace, or decorate the default?
  • 2. Register the component
  • 3. Component skeleton and available props
  • 4. Fetch your own data
  • 5. Render with native `@payloadcms/ui` components — don't hand-roll
  • Using the native `Table` component with your own data (not field-driven)
  • 6. The `Gutter` + custom CSS padding pitfall (read this before adding any page-level padding)
  • 7. Batched lookups for anything that needs cross-collection or resolved data
  • 8. Regenerate the import map
  • 9. Verify
  • Recipe checklist (copy this into your task)
On this page:
  • 1. Decide: full replace, or decorate the default?
  • 2. Register the component
  • 3. Component skeleton and available props
  • 4. Fetch your own data
  • 5. Render with native `@payloadcms/ui` components — don't hand-roll
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

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.