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:
"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.
"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}><TemplateTablerows={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}/><TemplatePagerpage={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.
const c = useResourceController<Product>(productsResource)// datac.rows // Product[]c.meta // { total, page, page_size, pages } | undefinedc.total // numberc.totalPages // numberc.isLoading // boolean// query state — sort/page/filters, and the date range round-trips through the URLc.page c.setPage(n)c.pageSize c.setPageSize(n)c.search c.setSearch(value)c.sortBy c.sortOrder c.setSort(key) // toggles directionc.filters c.setFilter(key, value)c.dateRange c.setDateRange(range)// columns — visible ones, ready to renderc.columns c.allColumns c.hiddenColumns c.toggleColumn(key)// selectionc.selection c.setSelection(ids) c.clearSelection()// actionsc.actions c.can("create" | "view" | "edit" | "delete")c.create() c.edit(row) c.view(row)c.remove(id) // opens the confirm dialogc.bulkRemove() // opens the bulk confirm dialogc.isDeleting c.isBulkDeleting// dialog state, if you render your ownc.form // { open, item, close() }c.confirmDelete // { open, confirm(), cancel() }c.confirmBulkDelete // { open, confirm(), cancel() }c.importer // { open, setOpen(open) }// odds and endsc.apiSearchParams // the same query the table ran — use it for exportsc.stats // stat cards, already scoped to the active date rangec.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:
const c = useResourceController(productsResource);return (<div><PageHeader title={c.pluralName} stats={c.stats} /><TableToolbarresource={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:
products.ts # generated — rewritten on every grit generateproducts.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.
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 elsecolumns: {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 exactlyDataTable's props:columns,data,isLoading,sortBy,sortOrder,onSort,selectedRows,onSelectRows,onView,onEdit,onDelete,rowActions.Form— receivesresource,item(the record being edited, ornullfor create) andonClose. Replaces whichever containerformViewwould 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:
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:
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.
