One Table, Four Audiences
The clinical data pipeline I've been building shows records to four very different kinds of user. Instead of four tables, there's one pattern: server-side pagination, state in the URL and a single scoping rule applied on the server. Here's how it fits together.
5 min read
In the clinical data pipeline I've been building, the same kind of page shows up again and again: a list of records. And it's seen by four very different audiences:
- Platform admins, who need to see everything.
- Network users, who see records across the organisations in their network.
- Providers, who see their own organisation's records.
- Patients, who see only their own.
This month I rebuilt all of those pages on one pattern. It's not clever. That's the point. Here's what it looks like and why each piece is there.
The problem with four tables#
The obvious way to build this is four pages with four queries and four table components. Each one is a little different. Each one gets fixed separately. One of them eventually forgets a filter.
That last risk is the one that matters. With health data, a list page that shows one record too many isn't a cosmetic bug. It's a privacy incident.
So the goal wasn't only less code. It was making the dangerous part, deciding who sees what, impossible to get wrong on one page and right on another.
Piece one: scoping happens once, on the server#
Every scoped query goes through the same helper. It takes the query and the user's session, and adds the right filter:
applyScope(query, session):
if session is platform admin: return query
if session is network user: return query where org in session.networkOrgs
if session is provider: return query where org = session.org
if session is patient: return query where patient = session.patient
otherwise: return nothing
The page doesn't decide scope. It asks for records and passes the session. The helper decides.
That gives one place to review, one place to test and one place to fix. Adding a new list page means calling the same helper, not writing a new rule. The last line is worth insisting on in any version of this: if a session doesn't match a known role, the answer should be empty, not everything.
The session itself is cached per request on the server, so a page with several components asking "who is this?" still does one lookup.
Piece two: paginate on the server#
Loading a chunk of records and paginating in the browser is fine for a demo. It falls apart with real data: slow first loads, wrong totals and a temptation to fetch "just a bit more" until the page is shipping thousands of rows.
Now every records page asks the database for exactly one page. The query asks for a range and an exact count in the same call, so the table knows how many pages there are without loading them.
Page sizes are set per audience. Admins get 50 by default and can switch to 25 or 100. Provider and network pages use 50. The patient portal uses a smaller page of 25.
A couple of upload-related pages still use client-side mode with a fixed limit. They show recent uploads, not a searchable archive, so it's fine for now. It's written down as a known difference rather than left as a surprise.
Piece three: put the table state in the URL#
Page number, page size, sort and search live in the URL's query string, not in component state.
That sounds minor. It changes a lot:
- Pages are bookmarkable. "Page 3, sorted by date, filtered to this name" is a link.
- Pages are shareable. A support person can send an admin exactly what they're looking at.
- Back and forward work. The browser's history is the table's history.
- The server can render the right page directly. It reads the URL, runs the scoped query for that page and returns it. No flash of the wrong data while the client catches up.
/records?page=3&size=50&sort=date.desc&q=smith
server:
read params, with safe defaults and limits
session = getSession() # cached per request
rows, total = applyScope(recordsQuery, session)
.sort(sort).search(q).range(page, size)
render table(rows, total, params)
Two cautions. Validate every parameter, because the URL is user input: clamp the page size to the allowed values and only accept known sort columns. And never put anything sensitive in the URL. Search terms on a patient list are a judgement call. Treat URLs as something that ends up in logs and browser history, because they do.
Piece four: one table component#
On the screen side, the admin list pages now share a single list table component built from TanStack Table and shadcn primitives: the same toolbar, the same search box, the same sorting and pagination controls, the same badges.
I chose that pairing over a packaged data-grid library for a reason I keep coming back to: control. TanStack handles the hard logic of tables, like sorting, filtering and pagination state, without any markup. The shadcn components handle the look, and they live in the codebase. Together they give full control over columns and behaviour while staying consistent with the rest of the design system.
The shared component also pulls page metrics into the header, so every list shows its key numbers the same way.
Why not row-level security alone#
A fair question is why scoping lives in application code at all, when databases can enforce row-level rules themselves.
Database policies are a strong layer, and they're worth having where you can. But the application still has to know the user's scope to build the right page: which organisations a network user can see, which patient a portal user is. Putting that logic in one helper keeps it readable and testable in the same language as the rest of the app.
The important part isn't which layer does it. It's that there's exactly one place in the app that decides, and every list goes through it. Tests then cover the cases that matter most: a provider asking for another organisation's records, a patient guessing another patient's ID, and a session with no recognised role.
Small things that came along#
A pattern like this tends to pull small cleanups with it:
- Names got consistent. "Users" became "Members" across the navigation, headers and pages, because that's what they are inside an organisation.
- Every list page looks the same. That's good for users, who learn one table once. It's also good for reviewers, who can spot a page that doesn't follow the pattern.
- New list pages are cheap. Define the columns, call the scoped query, pass the URL params. The rest is shared.
What I'd do the same way again#
If you're building list pages for users with different levels of access, here's the short version:
- Make scoping one server-side function that every query goes through, with an empty result as the default.
- Paginate in the database, with an exact count, from day one.
- Keep table state in the URL, and validate it like any other input.
- Share one table component so behaviour and look stay consistent.
- Write down the exceptions, like the pages that still paginate in the browser, so they're decisions, not accidents.
It comes from an instinct that runs through the whole system: make the safe path the only path.
The takeaway#
Four audiences don't need four tables. One server-side scoping rule, database pagination, state in the URL and a shared table component give every user a fast, linkable list of exactly the records they're allowed to see. The pattern is boring, and with health data, boring is what you want.
Related
- Engineering
The Key Trail: The Easy Way to Run Form-Heavy CMSs
Let the route's params pick the data, the state path and the form. In a form-heavy CMS or admin tool, a new screen becomes one route plus one field array, with no new endpoint and no new form code.
Forms · State management · Architecture · Front-end3 min read
- Design
Swapping a Component Library Without Stopping the Product
Moving a React app from MUI to shadcn-style primitives sounds like a rewrite. It doesn't have to be. Here's the approach I set up: codemods for the boring parts, a thin layer of your own components, and a migration that ships one screen at a time.
Design systems · React · Front-end5 min read
- Leadership
The Tech Job Exodus Isn't New: When Opening the File Stopped Being Enough
Around 2012 and 2013, knowing how to operate the tools stopped being enough to hold a job on the web. You had to be able to build. The AI shift feels like that again, only faster. What I saw then, what's different now, and what I'd do about it.
Careers · AI · Front-end · Leadership6 min read