Headless Tables
The data-table-remote block renders a table from an API endpoint. You publish
a manifest describing your data; the table derives columns, filters, sheet
fields, row identity and URL state from it. You keep full control of the data —
the only thing you hand over is the shape of it.
This is the same engine as every other page in these docs. The difference is where the schema lives: in a TypeScript file you import, or in a response from an endpoint you own.
Installation
npx shadcn@latest add \
https://data-table.openstatus.dev/r/data-table.json \
https://data-table.openstatus.dev/r/data-table-schema.json \
https://data-table.openstatus.dev/r/data-table-query.json \
https://data-table.openstatus.dev/r/data-table-nuqs.json \
https://data-table.openstatus.dev/r/data-table-remote.jsonThe manifest
A manifest is what an endpoint says about itself. It is JSON, so it crosses the network intact:
type TableManifest = {
version: number;
schema: SchemaJSON;
/** The column that identifies a row on the wire. */
primaryKey: string;
/** A template over column keys: "{method} {pathname}". */
rowLabel?: string;
capabilities: TableCapabilities;
chart?: TableChartConfig;
actions?: ActionDescriptor[];
defaults?: { sort?; size?; columnVisibility? };
};primaryKey and rowLabel replace the closures every client used to write by
hand (getRowId={(row) => row.uuid}) — exactly the kind of thing a table
pointed at an endpoint cannot supply for itself.
Serving it
createTableManifest builds one from a schema you already have;
createTableManifestHandler serves it with ETag revalidation.
// app/logs/api/schema/route.ts
import {
createTableManifest,
createTableManifestHandler,
} from "@/lib/table-schema";
import { tableSchema } from "../table-schema";
const handler = createTableManifestHandler(
createTableManifest({
schema: tableSchema,
primaryKey: "uuid",
rowLabel: "{method} {pathname}",
capabilities: {
facets: true,
totalRowCount: true,
filterRowCount: true,
chart: true,
backwardPagination: true,
},
defaults: { sort: { id: "date", desc: true }, size: 40 },
}),
);
export async function GET(request: Request) {
return handler(request);
}Pass a function instead of a value when the manifest depends on the request — per-tenant columns, or actions that depend on the caller's permissions:
const handler = createTableManifestHandler((request) =>
createTableManifest({
schema: tableSchema,
primaryKey: "uuid",
capabilities: { facets: true, actions: canWrite(request) },
...(canWrite(request) ? { actions: actionHandler.descriptors } : {}),
}),
);createTableManifest throws if primaryKey is not a column in the schema.
Better to fail on the server than to ship a manifest whose rows the client
cannot identify.
Rendering it
"use client";
import { DataTableRemote } from "@/components/data-table/data-table-remote";
import { searchParamsSerializer } from "./search-params";
export function Client() {
return (
<DataTableRemote
manifestEndpoint="/logs/api/schema"
searchParamsSerializer={searchParamsSerializer}
/>
);
}The data endpoint defaults to the manifest URL with a trailing /schema
stripped, so /logs/api/schema pairs with /logs/api. Set dataEndpoint
explicitly when they do not line up.
Why it is two components
The URL-state adapter's parsers are derived from the schema, and
useNuqsAdapter(schema, …) needs one at first render. A hook cannot be called
conditionally, so the manifest has to resolve one level above everything
built from it. DataTableRemote resolves it; the inner table is mounted only
once it has, and therefore has a stable hook order for its whole life.
The inner table is keyed on the column set, so a schema change resets table state. That is deliberate: sorting, visibility and filters are keyed by column, and carrying them across a schema swap resurrects state for columns that no longer exist. A capability or action change does not remount — it should not throw away the user's sorting.
Capabilities
The client used to assume the server did everything. An endpoint that cannot group facets or count matching rows had no way to say so, and the UI had no way to degrade — it rendered empty filters and a blank count.
| Flag | When false |
|---|---|
facets | The table facets the rows it has loaded, client-side |
totalRowCount | The total is left blank, not shown as 0 |
filterRowCount | The filtered count is left blank |
chart | The chart slot is not rendered at all |
backwardPagination | The live button is not rendered, rather than rendered and inert |
actions | No action column, no bulk bar |
Every capability defaults to off. A table that renders no chart against a server that has one is a missing feature; a table that renders a chart against a server that has none is a broken screen.
Use facetedColumns when the server can only facet some columns:
capabilities: {
facets: true,
facetedColumns: ["level", "status", "latency"],
}Transport
Everything about how the table talks to an endpoint is one option — base URL, headers, credentials, and how the body is parsed. See Data Fetching for the full surface.
<DataTableRemote
manifestEndpoint="https://api.example.com/logs/schema"
dataEndpoint="/logs"
searchParamsSerializer={searchParamsSerializer}
transport={{
baseUrl: "https://api.example.com",
// A function, so a token is read fresh per request rather than captured
// once at module scope.
headers: async () => ({ authorization: `Bearer ${await getToken()}` }),
credentials: "include",
// The endpoint returns ordinary JSON rather than SuperJSON.
parseResponse: jsonParser(),
}}
pagination={offsetPagination({ size: 50 })}
/>To revive timestamp columns from ISO strings you need the schema, which means
the manifest — so reach for schemaJsonParser where you already have one. With
a snapshot that is straightforward:
import { manifest } from "./manifest"; // a build-time snapshot
<DataTableRemote
manifestEndpoint="/logs/api/schema"
searchParamsSerializer={searchParamsSerializer}
initialManifest={manifest}
transport={{ parseResponse: schemaJsonParser(manifest.schema) }}
/>;A cross-origin endpoint needs CORS, and its action hrefs need to be
allow-listed — see Trust boundary.
Avoiding the round trip
Fetching the manifest before the first row request costs a round trip. Two ways
out, both through initialManifest.
Prefetch on the server
// page.tsx
import { fetchTableManifest } from "@/lib/table-schema";
export default async function Page() {
const manifest = await fetchTableManifest(
"http://localhost:3000/logs/api/schema",
);
return <Client manifest={manifest} />;
}Or seed the React Query cache under the exported tableManifestKey(endpoint)
and let the hook read it.
Snapshot at build time
Check the endpoint's answer into the repo:
// scripts/pull-manifest.ts
import { writeFileSync } from "node:fs";
import { pullManifestModule } from "@/lib/table-schema";
writeFileSync(
"src/app/logs/manifest.ts",
await pullManifestModule("https://api.example.com/logs/schema"),
);That writes a typed module with a header recording where and when it came from.
Pass it as initialManifest and the first paint has a schema; the query still
revalidates behind it, so an endpoint whose schema has moved on corrects itself.
Regenerate the file when that happens, or every first paint starts from stale
columns.
manifestToModule validates on the way in, so a snapshot cannot freeze an
invalid manifest into your repo.
Custom renderers
A schema carries named displays — badge, bar, heatmap, status-code,
timestamp and the rest — and those travel as data. Renderer closures do not.
applyRenderers is the escape hatch: supply the handful of columns you want to
draw yourself, by key, and every other column stays declarative.
<DataTableRemote
manifestEndpoint="/logs/api/schema"
searchParamsSerializer={searchParamsSerializer}
renderers={{
pathname: { cell: (value) => <PathnameCell value={String(value)} /> },
timing: { sheetComponent: (row) => <TimingPhases row={row} /> },
}}
/>The descriptor is untouched, so a column with a custom cell still declares
bar on the wire and the schema still round-trips — another consumer of the same
endpoint renders something sane rather than nothing. An override naming a column
that is not in the schema warns instead of throwing, because a remote schema can
drop a column between deploys and a stale override should not blank the table.
Slots
Anything a manifest cannot describe is a slot:
<DataTableRemote
manifestEndpoint="/logs/api/schema"
searchParamsSerializer={searchParamsSerializer}
loadingSlot={<Skeleton />}
errorSlot={(error) => <TableError error={error} />}
chartSlot={(data, config) => <TimelineChart data={data} config={config} />}
sheetSlot={(fields) => <SheetDetails fields={fields} />}
getRowClassName={(row) => getLevelRowClassName(row.original.level)}
/>Row styling is a slot on purpose. It is presentation policy that varies per app against the same data, and encoding conditional CSS in a JSON manifest would mean inventing a rules language for one caller. The manifest says what a row is; the app decides what it looks like.
Trust boundary
A remote manifest is untrusted input that drives both rendering and network calls. It is validated once, on the way in:
- Bounds — at most 200 columns and 50 actions, with key and label length caps. Columns that are unusable are dropped with a warning rather than blanking the table; the parser throws only when nothing is renderable.
- Action hrefs — a root-relative path is always allowed; an absolute URL only
on the page's own origin or one you allow-list. Protocol-relative
(
//evil.example.com),javascript:,data:,blob:and directory-relative paths are rejected, and a descriptor that fails is never rendered — a button that cannot be sent safely is worse than no button.
<DataTableActionsProvider
actions={actions}
getRowId={(row) => row.uuid}
allowedActionOrigins={["https://api.example.com"]}
>Proving an endpoint conforms
runEndpointConformance probes a live endpoint against the contract:
import {
formatConformanceReport,
runEndpointConformance,
} from "@/lib/data-table";
const report = await runEndpointConformance({
url: "https://api.example.com/logs",
manifest,
});
console.log(formatConformanceReport(report));
if (!report.ok) process.exit(1);It checks that the endpoint answers, that the shape matches, that size is
honoured, that every row carries the primaryKey and those keys are unique,
that it computes the facets it claims, that a second page differs from the first
— the check that catches an endpoint silently ignoring cursor, which is an
infinite scroll that never advances — that direction=prev works when declared,
and that an unknown query parameter is ignored.
It does not check filter semantics; that needs known data and belongs to the filter corpus. See Data Layer for the request and response contract itself.
Demo
The /drizzle route serves a real manifest at
/drizzle/api/schema, describing its own capabilities, chart and actions.
