Admin Panel

Custom pages and tables

Bring your own table, your own form, your own page shell — and keep the URL-synced sorting, paging, filters, selection, bulk delete and toasts that the stock page already has.

The short version

A generated resource page is three lines, and you are allowed to replace them:

apps/admin/app/(dashboard)/resources/products/page.tsx
"use client";
import { ResourcePage } from "@/components/resource/resource-page";
import { productsResource } from "@/resources/products";
export default function ProductsPage() {
return <ResourcePage resource={productsResource} />;
}

The reason people did not replace it was everything they would lose by doing so. The data was never the problem — useResource has always been a plain hook that takes an endpoint. The problem was the rest of the page: search, sort, page and filters kept in the address bar so a refresh or a shared link rehydrates the same view, row selection, bulk delete behind a confirm, toasts, cache invalidation, and stat cards that follow the active date range instead of contradicting the table under them.

That is now a hook. useResourceController returns all of it and renders nothing.

apps/admin/app/(dashboard)/resources/products/page.tsx
"use client";
import { useResourceController } from "@/hooks/use-resource-controller";
import { productsResource } from "@/resources/products";
import { TemplateShell, TemplateTable, TemplatePager } from "@/components/template";
export default function ProductsPage() {
const c = useResourceController(productsResource);
return (
<TemplateShell title={c.pluralName} onAdd={c.create}>
<TemplateTable
rows={c.rows}
columns={c.columns}
loading={c.isLoading}
sortKey={c.sortBy}
sortDir={c.sortOrder}
onSort={c.setSort}
selected={c.selection}
onSelect={c.setSelection}
onRowClick={c.edit}
/>
<TemplatePager
page={c.page}
pages={c.totalPages}
total={c.total}
onChange={c.setPage}
/>
</TemplateShell>
);
}

Why this is safe

The stock ResourcePage is built on the same hook and contains no state of its own. If the controller could not rebuild the default page, it would be missing something — so anything the default page can do, yours can too.

What the controller returns

Every setter that changes the query resets to page one, because a search run from page seven should not land on an empty page seven of two results. setSort toggles direction when you pass the same key twice.

the shape
const c = useResourceController<Product>(productsResource)
// data
c.rows // Product[]
c.meta // { total, page, page_size, pages } | undefined
c.total // number
c.totalPages // number
c.isLoading // boolean
// query state — sort/page/filters, and the date range round-trips through the URL
c.page c.setPage(n)
c.pageSize c.setPageSize(n)
c.search c.setSearch(value)
c.sortBy c.sortOrder c.setSort(key) // toggles direction
c.filters c.setFilter(key, value)
c.dateRange c.setDateRange(range)
// columns — visible ones, ready to render
c.columns c.allColumns c.hiddenColumns c.toggleColumn(key)
// selection
c.selection c.setSelection(ids) c.clearSelection()
// actions
c.actions c.can("create" | "view" | "edit" | "delete")
c.create() c.edit(row) c.view(row)
c.remove(id) // opens the confirm dialog
c.bulkRemove() // opens the bulk confirm dialog
c.isDeleting c.isBulkDeleting
// dialog state, if you render your own
c.form // { open, item, close() }
c.confirmDelete // { open, confirm(), cancel() }
c.confirmBulkDelete // { open, confirm(), cancel() }
c.importer // { open, setOpen(open) }
// odds and ends
c.apiSearchParams // the same query the table ran — use it for exports
c.stats // stat cards, already scoped to the active date range
c.singularName c.pluralName

Replacing one piece at a time

You do not have to take the whole page. Keep the stock layout and swap only the table, because c.columns and c.rows are ordinary values:

keeping the toolbar, replacing the table
const c = useResourceController(productsResource);
return (
<div>
<PageHeader title={c.pluralName} stats={c.stats} />
<TableToolbar
resource={productsResource}
search={c.search}
onSearch={c.setSearch}
selectedCount={c.selection.length}
onBulkDelete={c.bulkRemove}
allColumns={c.allColumns}
hiddenColumns={c.hiddenColumns}
onToggleColumn={c.toggleColumn}
data={c.rows}
dateRange={c.dateRange}
onDateRangeChange={c.setDateRange}
apiSearchParams={c.apiSearchParams}
/>
{/* your table, Grit's everything else */}
<TemplateTable rows={c.rows} onSort={c.setSort} />
</div>
);

Registering it once: the .custom.tsx file

Editing the route file works, but it only customises that one route. The detail page, a relationship picker and anything else rendering the resource still get the stock components. To set it once and have every route pick it up, use the customisation file that sits next to the resource:

apps/admin/resources/
products.ts # generated — rewritten on every grit generate
products.custom.tsx # yours — created once, never touched again

The split is what makes both halves safe. The config half can be regenerated freely because nothing of yours is in it. The custom half can hold components because it is a .tsx file and the generator will not overwrite it — it checks whether the file exists and leaves it alone if it does.

apps/admin/resources/products.custom.tsx
import type { ResourceCustomisation } from "@/lib/resource";
import { DataTable } from "@/components/tables/data-table";
import { StatusPill, TemplateTable } from "@/components/template";
const custom: ResourceCustomisation = {
// 1. Override a single cell, keep everything else
columns: {
status: { cell: (row) => <StatusPill value={String(row.status)} /> },
price: { cell: (row) => <b>{"$" + Number(row.price).toFixed(2)}</b> },
},
components: {
// 2. Replace the table. Same props DataTable takes, so this is a drop-in —
// header, toolbar, filters and pagination all keep working.
Table: (props) => <TemplateTable rows={props.data} onSort={props.onSort} />,
// 3. Or wrap the original instead of replacing it
// Table: (props) => <TemplateCard><DataTable {...props} /></TemplateCard>,
// 4. Replace the whole page — call useResourceController inside it
// Page: MyProductsPage,
},
};
export default custom;

Typed rows

The overlay is generic over the row type, and the generated stub imports it from @repo/shared/types — the same interfaces grit sync produces from your Go structs. So row in a cell renderer is a Product, not Record<string, unknown>: fields autocomplete, and renaming a column in Go turns every stale renderer into a compile error instead of a blank cell.

columns and fields are patched by key, not replaced wholesale. That is deliberate: grit sync keeps adding new columns as you add fields to the Go model, and your renderers survive it. A key that does not match any generated column is simply ignored.

The slots

  • Table — receives exactly DataTable's props: columns, data, isLoading, sortBy, sortOrder, onSort, selectedRows, onSelectRows, onView, onEdit, onDelete, rowActions.
  • Form — receives resource, item (the record being edited, or null for create) and onClose. Replaces whichever container formView would have opened.
  • EmptyState — rendered instead of the table when the query has finished and returned nothing.
  • Page — replaces the entire list view. Checked before anything else, so a page slot owns its own routing.

Wrapping instead of replacing

Because a slot receives the stock component's own props, you can render the original inside yours. That is the cheap way to restyle a shell or add something around a table without reimplementing sorting and selection:

wrapping the default
import { DataTable } from "@/components/tables/data-table";
components: {
Table: (props) => (
<div className="rounded-2xl border border-dashed p-2">
<p className="mb-2 text-xs text-muted-foreground">
{props.data.length} rows on this page
</p>
<DataTable {...props} />
</div>
),
}

Pages that are not resources

Porting a whole template means analytics, settings and billing screens that are not CRUD over a table. Those do not need the controller at all — use the data hooks directly against any endpoint your API exposes:

a page with no resource behind it
import { useResource } from "@/hooks/use-resource";
export default function RevenuePage() {
const { data, isLoading } = useResource<Invoice>("/api/invoices", {
pageSize: 100,
filters: { status: "paid" },
});
if (isLoading) return <TemplateSkeleton />;
return <TemplateRevenueChart rows={data?.data ?? []} />;
}

Will the generator overwrite this?

No. grit generate resource writes resources/<name>.ts and the thin page wrapper. Once you have replaced the wrapper with your own component, re-running the generator for a new resource does not touch it. What the generator does keep maintaining is the resource definition — and grit sync only ever inserts into it, between the grit:cols:auto-start and grit:fields:auto-start fences, so hand-edited labels and formats survive.