Record lists and My work
Show record binding columns on a list page, keep its state in the URL, merge a record's bindings into one timeline, and list what waits on the caller across blocks.
Record bindings load the parts of one record page.
The same bindings also fill list columns, feed a record's timeline and,
through the subjects block, a "My work" page across every installed block.
All of it lives in better-supabase/records, with Server Component helpers in
better-supabase/next and a URL state hook in better-supabase/react.
A list with binding columns
defineRecordList pairs a list query with a record
type and names the bindings shown as columns. Each column is filled for
the whole page in one wave, never one query per row:
| Column | Cell |
|---|---|
count | how many rows the binding has, from its count (one query for the page) |
data | the binding's rows, from its column, or one load per row in one wave |
import "server-only";
import { recordListServer } from "better-supabase/next";
import { defineRecordList } from "better-supabase/records";
import { customerList } from "@/lib/lists";
import { customerRecord } from "@/lib/supabase/records/customer";
import { bsb } from "@/lib/supabase/server";
export const customerRecordList = defineRecordList(
customerRecord,
customerList,
{ columns: { comments: "count" } },
);
export const customerRecords = recordListServer(bsb, customerRecordList);load(searchParams) parses the URL state, runs the list query and fills the
columns. It returns { query, page, rows, filterFields, can }: each row holds
its subject, its row and its cells, and every cell settles on its own
like a binding (ok, error or hidden). A column the caller can't read is
hidden on every row and runs no query. Bad URL state fails with a
validation error that names the parameter:
import { customerRecords } from "@/features/customers/customer-record-list";
export async function GET(request: Request): Promise<Response> {
const result = await customerRecords.load(new URL(request.url).searchParams);
if (!result.ok) {
return Response.json(result.error, {
status: result.error.kind === "validation" ? 400 : 500,
});
}
return Response.json(result.data);
}recordListServer returns plain data (Temporal values become ISO strings), so
the page passes straight to a Client Component. Outside Next.js, call
customerRecordList.load({ db, clients, permissions }, searchParams) with the
repositories, the record type's block clients and the caller's permission set.
Keep the list state in the URL
useRecordListState keeps a list's sort, facets, filter and page in the URL
through nuqs. Pass it the list definition and the
useQueryStates result for the list's parsers:
"use client";
import { useRecordListState } from "better-supabase/react";
import { createParser, useQueryStates } from "nuqs";
import { customerList } from "@/lib/lists";
const parsers = customerList.nuqs(createParser);
export function useCustomerListState() {
return useRecordListState(customerList, useQueryStates(parsers));
}It returns query (the typed, parsed state, or the list's defaults when the
URL holds something the list rejects), searchParams for the fetch, set to
change keys (which goes back to page 1), setPage and reset. Wrap the app
in nuqs's NuqsAdapter once.
A record's timeline
loadTimeline(source, subject) loads every binding that maps its rows to
timeline entries and merges them, newest first. The comments, versions and
attachments bindings do this; a block's own binding adds a timeline hook
that turns a row into { id, kind, at, actor?, row }. A binding that fails or
that the caller can't read adds nothing and is listed in skipped:
const timeline = await customerPage.timeline(customerId, { limit: 20 });
timeline.entries; // [{ binding: "comments", kind: "comment", at, actor, row }]
timeline.skipped; // { versions: { kind: "hidden" } }bsb.record(...).timeline checks the caller's permissions first, so it never
queries a binding they can't read. Pass bindings to merge only some of them.
My work
The subjects block installs my_work(tenant, page_size): what waits on the
caller across the installed blocks, newest first. It runs as the caller, so
every row is one they can read.
| Kind | From | Rows |
|---|---|---|
assigned | tasks | open work items assigned to the caller |
approval | approvals | pending approval requests the caller can decide |
reminder | reminders | the caller's reminders sent in the last 7 days |
mention | comments | comments that mention the caller, last 30 days |
sla | sla | SLA clocks that warned on the caller's work items |
A kind whose block isn't installed returns nothing. loadMyWork groups the
items by kind and counts them for tab badges, in one round trip:
import { loadMyWork } from "better-supabase/records";
const work = await loadMyWork(db.subjects, { tenant }).orThrow();
work.groups.approval; // approvals the caller can decide
work.counts.assigned; // 3Each item has a kind, the subject it is about, the ref of the row that
put it there, and its title, url and at when the record has them.
The example app's customer list, customer timeline and /my-work page show
all four parts together.
Last updated on
Record bindings
Load the comments, files and tags of one record page in parallel, each settled, hidden when the caller can't read it, and kept current over one realtime subscription.
Access contract
One permission check for RLS policies and SQL modules, over a role list, your own role tables, an authorization provider or functions you already have.