Skip to content
Reusable List

Reference / Templates

Templates

Four composition layers, from a bare list to a fully-stateful page. Every example route in this project builds on some or all of them.

TL;DR

Every list page starts with ListPageShell for its shared page structure and PaginationControls for server-rendered navigation. Most examples use ResultsView to render domain-owned rows and cards, while Issues intentionally substitutes its own selectable table. A page-owned toolbar composes search, filters, sorting, and view controls around the shared URL state.

Selection is added only when the domain requires temporary row state, while loading, empty, filtered-empty, and error presentations remain explicit parts of every complete composition. The templates differ by capability, but they all reuse the same core instead of creating separate page architectures.

Composition layers

Each layer adds a small set of building blocks from src/features/list-page/ on top of the one before it.

Basic List

ListPageShell composed with ResultsView and PaginationControls. toolbar is a required prop, but a basic composition can pass it null or minimal content — every other layer just puts more into that same slot and into children.

  • ListPageShell
  • ResultsView
  • PaginationControls
Packages layers search and two filters on top
Preview: three dependency rows showing a package name and version, with no search or filter controls.

Search & Filters

A page-owned toolbar composes SearchField, SingleSelectFilter or MultiSelectFilter, SortMenu, and ViewSwitcher, then calls useListQueryState once. Debounced search replaces the current URL entry, while filter, sort, view, and pagination changes each push a new, navigable one — so the back button and a copied link still restore the right list.

  • SearchField
  • SingleSelectFilter
  • MultiSelectFilter
  • SortMenu
  • ViewSwitcher
  • useListQueryState
Components combines a single and a multi-value filter
Preview: a search field, an applied category filter, and two component rows each showing a status dot and label.

Selection & Bulk Actions

Adds row-level selection that is deliberately kept out of the URL — it's page state, not shareable list state. SelectionToolbar appears once anything is checked and exposes page-owned bulk actions; the page renders its own selectable table because ResultsView doesn't model table headers, select-all, or indeterminate selection.

  • useSelection
  • SelectionToolbar
Issues is the only example that needs it
Preview: a selection toolbar reading 2 selected with a Change status action, above two checked issue rows.

System States

Every example implements a loading.tsx and error.tsx route segment with ListSkeleton, ListErrorState, and useDemoErrorRecovery, plus an empty and filtered-empty branch inside its own results component using ListEmptyState. Route Handlers accept an explicit demoState query parameter so every state can be previewed without special tooling.

  • ListSkeleton
  • ListEmptyState
  • ListErrorState
  • useDemoErrorRecovery
Try appending ?demoState=error to Deployments
Preview: three loading skeleton bars of varying width.

How a page composes them

A fictional Widgets example, built the way every real example is: a Server Component page parses the URL once and renders the shared shell, while a narrow Client Component toolbar owns the interactive controls and writes back to that same URL.

Server Component — src/app/examples/widgets/page.tsx
export default async function WidgetsPage(
  props: PageProps<"/examples/widgets">,
) {
  const searchParams = toSearchParams(await props.searchParams);
  const demoState = parseDemoState(searchParams);
  const query = parseListQuery<WidgetSortKey, WidgetFilterKey>(
    searchParams,
    WIDGET_LIST_QUERY_CONFIG,
  );
  const { records, total, page } = await queryWidgets(query, demoState);

  const buildHref = (target: number) =>
    "/examples/widgets" +
    buildListQueryString(
      { ...query, page: target },
      WIDGET_LIST_QUERY_CONFIG,
    );

  if (page !== query.page) {
    redirect(buildHref(page));
  }

  return (
    <ListPageShell
      title="Widgets"
      toolbar={<WidgetsToolbar />}
      pagination={
        <PaginationControls
          page={page}
          pageSize={WIDGET_LIST_QUERY_CONFIG.pageSize}
          total={total}
          buildHref={buildHref}
          itemLabel="widgets"
        />
      }
    >
      <ResultsView
        view={query.view}
        items={records}
        getItemKey={(record) => record.id}
        renderListItem={(record) => <WidgetListRow record={record} />}
        renderGridItem={(record) => <WidgetGridCard record={record} />}
        listAriaLabel="Widgets"
      />
    </ListPageShell>
  );
}
Client Component — src/features/widgets-example/widgets-toolbar.tsx
"use client";

export function WidgetsToolbar() {
  const { query, setSearch, setSort, setView, setSingleFilter } =
    useListQueryState<WidgetSortKey, WidgetFilterKey>(
      WIDGET_LIST_QUERY_CONFIG,
    );

  return (
    <div className="flex flex-wrap items-center gap-2.5">
      <SearchField
        label="Search widgets"
        placeholder="Search widgets…"
        value={query.search}
        onChange={setSearch}
      />
      <SingleSelectFilter
        label="Status"
        options={STATUS_OPTIONS}
        value={query.filters.status[0]}
        onChange={(value) => setSingleFilter("status", value)}
      />
      <div className="flex-1" />
      <SortMenu options={SORT_OPTIONS} value={query.sort} onChange={setSort} />
      <ViewSwitcher value={query.view} onChange={setView} />
    </div>
  );
}