Card Views

Replace a module's table with your own card grid, and drive the query - projection, sort, filters, paging - from your own UI.

The file to edit is src/overrides/crud/cards.tsx. A card view replaces the table on a module's list page. Registering one adds a table/cards toggle to the page header and makes cards the default:

src/overrides/crud/cards.tsx
import type { TCardsOverride } from "@/core/types"
import JobsList from "@/pages/jobs/JobsList"

export const cards: TCardsOverride = { jobs: JobsList }

That header row can itself be extended with a button, or replaced wholesale - see List Actions and List Toolbar. Note that replacing the row removes the table/cards toggle along with it.

Table vs Card List

The two list views fetch differently, and that difference is the reason to choose one over the other:

Table (default)Card view (an override)
Fetchingthe whole collection, in one requestone page at a time, offset/limit
Paging, sortingclient-side, owned by the table itselfserver-side; core renders the pager below your component
Fetch triggerautomaticyour component must call fetcher()

The table loads the full set on purpose: it then pages, sorts and filters client-side with no further round trips. Card views take the server-paginated path instead, which is what you want when the view should fetch incrementally.

Critical: a Card View Must Call fetcher()

In card mode the list page's fetch hooks are created with manualTrigger: true, so nothing is requested until your component asks for it. Call fetcher() from an effect, or your cards render forever against an empty data array.

React.useEffect(() => {
  fetcher(zodToMongoProjection(jobSchema))
}, [fetcher])

fetcher(projection?, sorting?) both triggers the request and - if you pass them - sets the projection and sort for it. Projections matter here: a card usually needs a handful of fields, not the whole document.

zodToMongoProjection (from @/core/lib/zodToMongoProjection) walks a zod schema and returns { field: 1 } for every leaf, flattening nested objects to dot paths (education.fieldOfStudy) and arrays without an index segment. Declaring the schema once gives you the projection and a parser for the unknown[] you receive back.

A Minimal Card View

Everything a card view must do, and nothing else:

src/pages/jobs/JobsList.tsx
import React from "react"
import z from "zod"
import type { TCardProps } from "@/core/types"
import { zodToMongoProjection } from "@/core/lib/zodToMongoProjection"
import { Card, CardContent } from "@/components/ui/card"

// Declared once: drives the projection AND parses the response.
const jobSchema = z.object({
  _id: z.string(),
  title: z.string(),
  salary: z.number().optional(),
})

export default function JobsList({ data, isLoading, fetcher }: TCardProps) {
  // REQUIRED: card mode does not fetch on its own.
  React.useEffect(() => {
    fetcher(zodToMongoProjection(jobSchema))
  }, [fetcher])

  if (isLoading) return <JobsSkeleton />

  // `data` is `unknown[]` - parse it with the same schema.
  const jobs = jobSchema.array().parse(data)

  return (
    <div className="grid gap-3 p-2 sm:grid-cols-2 xl:grid-cols-3">
      {jobs.map((job) => (
        <Card key={job._id}>
          <CardContent className="p-4">
            <h3 className="font-semibold">{job.title}</h3>
            {job.salary && <small className="text-muted-foreground">{job.salary}</small>}
          </CardContent>
        </Card>
      ))}
    </div>
  )
}

That is the whole contract: fetch on mount, parse what comes back, render it. Everything below is optional.

Adding the Extras

Let people select rows

Selection is shared with the core action bar - call toggleSelect and the delete/edit bar at the bottom of the page lights up with no extra work:

<Checkbox
  checked={selectedIds.includes(job._id)}
  onCheckedChange={() => toggleSelect(job._id)}
/>

Pull selectedIds and toggleSelect from props. With no argument, toggleSelect() toggles the whole page.

Drive the query from your own controls

Each of these triggers a refetch through the same cached query the table uses:

<Button onClick={() => setSort({ salary: -1 })}>Highest paid</Button>
<Button onClick={() => setFilters({ isOpen: true })}>Open only</Button>

Set the detail page's breadcrumb

state.name becomes the trailing crumb on the page you navigate to:

navigate(`./${job._id}`, { state: { name: job.title } })

useBreadcrumbTitle() does the same thing from inside a page.

Virtualise long lists

react-virtuoso is already a dependency. Swap the plain .map() for a grid, and pass customScrollParent so the cards scroll inside the page shell rather than the window:

const [scrollParent, setScrollParent] = React.useState<HTMLDivElement | null>(null)

<div ref={setScrollParent} className="h-full overflow-y-auto">
  <VirtuosoGrid
    customScrollParent={scrollParent ?? undefined}
    data={jobs}
    computeItemKey={(_, job) => job._id}
    listClassName="grid gap-3 sm:grid-cols-2 xl:grid-cols-3"
    itemContent={(_, job) => <JobCard job={job} />}
  />
</div>

The Props

TCardProps:

PropUse
data: unknown[]the records - parse them yourself
isLoadingfirst load; show a skeleton
isRefetchinga later load; usually a subtler indicator
fetcher(projection?, sorting?)trigger a fetch, optionally setting projection and sort
setProject(projection)change the projection without fetching
setSort({ field: 1 })change sort order (1 or -1); triggers a refetch
setFilters(filters)set the SDK-level filters for the query
setPage(index)zero-based page index
selectedIdsthe _ids currently selected
toggleSelect(id?)toggle one row; with no argument, toggles the whole page
namethe module name, for cache keys or labels

Pagination is rendered by the list page below your component, driven by the same page/pageSize it passes into the query. Page size comes from VITE_DEFAULT_PAGINATION_LIMIT. You only need setPage if you want to drive paging yourself, for example from an infinite scroller.


On this page