Every internal admin tool starts clean. One screen lists articles: a table, a few count cards across the top, a status pill. Someone writes it in an afternoon and it's good.
Then comes the pages screen. It's almost the same — a table, count cards, no status pill this time. So it gets copy-pasted and trimmed. Then a users screen, a media screen, an audit-log screen. Six months later the tool is a graveyard of near-identical table components, each with its own subtly different pagination handling, its own loading spinner, its own idea of what an empty state looks like. Fixing a bug means fixing it five times, and you'll miss one.
The reflex fix is to reach for a single mega-component — a <SuperTable> with forty props and a variant enum — and wire every screen through it. That trades a copy-paste problem for a God-object problem. The component grows a new prop every time a screen needs something slightly different, and eventually no one can change it without breaking a caller three domains away.
The better move is to change what a screen is. Not a component you configure, but data you declare.
A screen is a descriptor
The unit I settled on is a plain description of the screen — its columns, and the numbers it summarises — with no rendering logic in it at all:
export interface ColumnDef<Row> {
field: string;
header: string;
flex?: number;
width?: number;
minWidth?: number;
sortable?: boolean;
type?: "date" | "number";
/** Derive the sortable/display value from the row. */
value?: (row: Row) => unknown;
/** Custom cell content, e.g. a status pill. */
render?: (row: Row) => ReactNode;
}
export interface StatCard<Row> {
label: string;
compute: (rows: Row[]) => number | string;
hint?: string;
}That's the whole contract a screen author touches. Everything about how a table paginates, sorts, shows a loading bar, or renders an error lives elsewhere — in a single generic component the domains never edit:
export function TablePage<Row extends GridValidRowModel>({
columns, rows, getRowId, loading, error, statCards, pageSize = 10,
}: TablePageProps<Row>) {
// maps ColumnDef -> the grid's native column shape, once
const gridColumns = useMemo<GridColDef<Row>[]>(
() => columns.map((c) => ({
field: c.field,
headerName: c.header,
flex: c.flex,
width: c.width,
minWidth: c.minWidth,
sortable: c.sortable ?? true,
type: c.type,
valueGetter: c.value ? (_v, row) => c.value!(row) : undefined,
renderCell: c.render ? (params) => c.render!(params.row) : undefined,
})),
[columns],
);
// ...renders the stat cards + the data grid
}Two small translations do the real work here. A column's value becomes the grid's valueGetter — the function that turns a row into a sortable, displayable cell value. A column's render becomes renderCell — arbitrary JSX for the cell, which is how a status pill or a link gets in without the framework knowing anything about statuses or links. The domain describes intent; the framework owns the mechanics of the grid library underneath. Swap that library out one day and no descriptor changes.
Why this is a DDD boundary, not just a helper
The split isn't only about avoiding duplication. It's a domain-driven design boundary drawn in the file tree — DDD in the one sense that earns its keep here: each part of the product (articles, pages, users) is a bounded context that lives in its own module, owns its own rules, and is not allowed to reach into another. The shared machinery lives somewhere those modules can depend on, but which depends on none of them.
On disk that's two top-level folders:
src/
framework/ # reusable, domain-agnostic — knows no domain
bodies/
TablePage.tsx # the generic, descriptor-driven list screen
features/ # bounded contexts (migrating to domains/)
articles/
ArticlesDataGrid.tsx # a descriptor, nothing more
StatusChip.tsx # article-specific cell content
pages/
PagesTable.tsx # a descriptor, nothing more
utils/api/ # typed client + query hooks
The folder is still called features/ — the conventional Next.js name — and is on its way to being renamed domains/, because bounded context is the honest label for what lives there. The name matters less than the rule it encodes: nothing in articles/ may import from pages/, and neither may be imported by framework/.
The diagram, in words: articles/ and pages/ each declare their columns against framework/'s TablePage and depend on its ColumnDef / StatCard types; framework/ depends on neither of them.
The dependency arrow only points one way. framework/ knows nothing about articles or pages — it can't, and that's enforced by it having no imports from the domains. Each domain owns its own descriptor and its data-fetching, and depends inward on the framework's types. A new domain can't accidentally couple itself to another domain through the shared table, because the shared table has no domain in it to couple to.
This is what makes "data-driven" more than a slogan. The domain layer declares what a screen is; the framework layer decides how it renders. The boundary between them is a set of serialisable-ish descriptors, not a call graph.
Here's what a domain screen actually looks like once the skeleton exists — the entire articles list:
const columns: ColumnDef<Article>[] = [
{ field: "title", header: "Title", flex: 2, minWidth: 240 },
{ field: "category", header: "Category", flex: 1, minWidth: 150 },
{ field: "author", header: "Author", flex: 1, value: (a) => a.author?.name ?? "—" },
{ field: "updatedAt", header: "Updated", width: 130, type: "date",
value: (a) => (a.updatedAt ? new Date(a.updatedAt) : null) },
{ field: "status", header: "Status", width: 140, sortable: false,
render: (a) => <StatusChip status={a.status} /> },
];
const statCards: StatCard<Article>[] = [
{ label: "Total", compute: (rows) => rows.length },
{ label: "Published", compute: (rows) => rows.filter((a) => a.status === "PUBLISHED").length },
];No JSX for the grid. No pagination. No loading state. The screen is a list of columns and two reducers over the rows. Everything else is inherited.
The test: a zero-diff refactor
Here's the part that tells you whether the boundary is in the right place.
I didn't build the skeleton first and then screens on top of it. I had a working, hand-written articles table already built and rendering live data. To validate the abstraction, I rewrote the existing screen to render through it — and watched for changes.
The proof you drew the boundary correctly is that refactoring an existing screen through the abstraction produces zero visual diff. If the screen changes, the abstraction is imposing opinions the screen didn't ask for.
The articles table looked and behaved identically before and after — same columns, same counts, same status pills, same sort behaviour. That's not an anticlimax; it's the whole result. A refactor that changes nothing the user can see, while collapsing a hand-written component into a ten-line descriptor, is the signal that the skeleton captured exactly the screen's structure and none of its incidental detail.
Then the second screen — pages — cost a descriptor and nothing else. Here's the whole thing — the same TablePage, fed a different descriptor:
const columns: ColumnDef<Page>[] = [
{ field: "title", header: "Title", flex: 2, minWidth: 240 },
{ field: "slug", header: "Route", flex: 1, minWidth: 160 },
{ field: "updatedAt", header: "Updated", width: 130, type: "date",
value: (p) => (p.updatedAt ? new Date(p.updatedAt) : null) },
];
const statCards: StatCard<Page>[] = [{ label: "Total", compute: (rows) => rows.length }];Put it next to the articles descriptor above and the entire difference between the two screens is the difference in their data: pages drop the status column — they have no lifecycle status — and keep one stat card instead of two. No new component, no second grid, no fifth reimplementation of pagination. The skeleton was already written.
Make the data typo-proof
There's an obvious objection to declaring screens as data: a descriptor is just an object literal, and an object literal is where typos go to hide. I hit exactly that. A pages column read:
{ field: "routeSlug", header: "Route" } // the real key is `slug`It compiled, it ran, and the column was blank for every row — the grid looked up row["routeSlug"], got undefined, and rendered nothing. No error, no crash. A silent bug that looks like a backend problem.
The fix is to make the descriptor's keys answer to the row type. Instead of field: string, bind it to the row's own property names:
// the one line that changes in ColumnDef<Row>:
field: Extract<keyof Row, string>; // was: field: stringNow "routeSlug" is not a valid field — the compiler rejects it and lists the keys that are valid. The typo becomes a build error, not a runtime mystery. Columns that genuinely have no backing property — an actions button, a row number — opt out through a separate variant, so "no field" is a decision you can see, never an accident.
Data-driven only pays off if the data is typed as tightly as code would be. A descriptor you can typo freely is just a config file waiting to rot.
That keyof binding is small, but it's what earns "screens as data" the right to be trusted — and it's the same generated row types (straight from the API's OpenAPI schema) doing double duty: rename a field on the backend and every stale descriptor turns into a compile error on the next codegen, instead of a blank column at runtime.
The discipline: name what you did not abstract
The failure mode of every framework is that it doesn't know when to stop. "Data-driven" slides into a configuration language that reinvents the host framework badly, and now you maintain a worse React inside React.
So the useful half of this work is the list of things I deliberately left concrete:
- Column headers are plain strings — even though the localisation primitive already exists in the codebase (
LocalizedText, a value that's either a literal string or a reference to a translation resolved per-locale). Wiring headers through it is a one-line type change —header: stringbecomesheader: LocalizedText— one I'd make the day a second language becomes real, not a speculative layer baked in on day one. - Stat cards recompute on every render, over the currently-loaded page of rows. For an internal admin tool with modest datasets, a
filterover a few hundred rows per render is free, and the simplicity is worth more than a memoised aggregate I'd have to invalidate correctly. That's an explicit data-driven over performance call, made with eyes open — and the kind of thing to revisit the day the numbers say to, not before. - The grid library shows through in the descriptor —
flex,minWidth. I chose not to invent a layout abstraction over the top of it, because a leaky one costs more than the honesty of naming the underlying grid's own knobs.
Every one of those is a place a purist would abstract further. Each would have added a layer I can't yet justify with a real requirement. The boundary is only in the right place if you can say, out loud, where it ends.
The take-home
A screen declared as data is not automatically better than a screen written as code. It's better when you have many screens that share a shape and a team that will keep adding to them — which is exactly the situation an internal admin tool is always in, whether or not anyone admitted it at the start.
The two things worth stealing, independent of the stack: draw the boundary as a one-way dependency — domains depend inward on a framework that knows nothing about them — and validate it with a zero-diff refactor of a screen you already trust. If the screen changes, you've abstracted the wrong thing. If it doesn't, you've earned the next screen for the price of a descriptor.
If you've drawn this boundary somewhere different — or think the whole descriptor move is a trap — I'd genuinely like to hear where it broke for you. The door's open at cordata.tech/contact.