Subjects
Titles and links for any record in config.records, resolved in one batched call, with the SubjectLink and SubjectChip recipes.
Comments, relations, approvals and the other blocks point at records as a
RecordRef, { type, id }. better-supabase/blocks/subjects turns those
refs into a title and a path from the subject registry,
so a list of rows that each name a record shows a link without a query per
row.
better-supabase add subjectsGive each type in config.records a title column and a url template:
export default defineConfig({
records: {
customer: {
table: "public.customers",
title: "name",
url: "/customers/{id}",
},
},
});resolve(refs) calls subject_titles and returns the readable subjects keyed
type:id. It runs as the caller, so a row the caller can't read, a
soft-deleted row and an unknown type are missing from the result. It removes
duplicate refs and sends at most 500 per call, splitting larger lists:
import { createBlockClient } from "better-supabase/blocks";
import { subjectKey, subjectsBlock } from "better-supabase/blocks/subjects";
const subjects = createBlockClient(db, subjectsBlock);
const found = await subjects
.resolve([
{ type: "customer", id: customerId },
{ type: "project", id: projectId },
])
.orThrow();
found[subjectKey({ type: "customer", id: customerId })];
// { type: "customer", id: "…", title: "Road Runner Inc", url: "/customers/…" }subject_titles fails with SUBJECT_REFS_INVALID when refs is not a JSON
array and SUBJECT_REFS_TOO_MANY above 500 refs.
summary(subject, sections, { seenAt }) returns one record's title and path
with a count and unread flag per binding, for a record page's header and tab
badges (Record bindings).
Paths in code
gen writes a urls constant to blocks.generated.ts with a function for
every subject type that has a url: block entities, config.records and app
table subjects. Each placeholder becomes a camel-case argument:
import { urls } from "@/lib/supabase/blocks.generated";
urls.customer({ id: customer.id }); // "/customers/…"
urls.task({ projectId: task.project_id }); // "/projects/…"In the browser
The React hooks are a registry item: npx shadcn add @better-supabase/use-subjects writes hooks/use-subjects.ts into the app.
useSubject(ref) returns subject: undefined while it loads, null when the caller can't read the
record, then { type, id, title, url }. useSubjects(refs) resolves a list
and returns get(ref). Every useSubject and useSubjects of one render
joins one subject_titles call, so a table with a subject link in each row
costs one round trip. Both expose error and refresh for a retry.
"use client";
import { useSubject } from "@/hooks/use-subjects";
export function SubjectLink({ subject }: { subject: RecordRef }) {
const { subject: resolved } = useSubject(subject);
if (resolved === undefined) return <Skeleton className="h-4 w-32" />;
if (resolved === null) return <span>A record you can't open</span>;
return <Link href={resolved.url ?? "#"}>{resolved.title}</Link>;
}createSubjectsController is the same logic without React. Vue, Svelte and
Solid run it with useBlockController from better-supabase/vue, /svelte
and /solid (see
Block controllers).
Wrap the client in batchSubjects so the components
of one render share a call. The Subject link and
Subject chip recipes show both in the Next.js example: the
related records panel and the approvals queue.
<script setup lang="ts">
import { computed } from "vue";
import { useBlockController, useSupabase } from "better-supabase/vue";
import { rpcTransport } from "better-supabase/blocks";
import {
batchSubjects,
createSubjects,
createSubjectsController,
subjectKey,
} from "better-supabase/blocks/subjects";
const props = defineProps<{ type: string; id: string }>();
const supabase = useSupabase();
const ref = { type: props.type, id: props.id };
const { state } = useBlockController(createSubjectsController, {
client: batchSubjects(
createSubjects({ transport: rpcTransport(supabase), schema: "api" }),
),
refs: [ref],
});
const subject = computed(() => state.value.subjects?.[subjectKey(ref)]);
</script>
<template>
<a v-if="subject?.url" :href="subject.url">{{ subject.title }}</a>
</template><script lang="ts">
import { getBetterSupabase, useBlockController } from "better-supabase/svelte";
import { rpcTransport } from "better-supabase/blocks";
import {
batchSubjects,
createSubjects,
createSubjectsController,
subjectKey,
} from "better-supabase/blocks/subjects";
let { type, id }: { type: string; id: string } = $props();
const { supabase } = getBetterSupabase();
const subjects = useBlockController(createSubjectsController, {
client: batchSubjects(createSubjects({ transport: rpcTransport(supabase), schema: "api" })),
refs: [{ type, id }],
});
</script>
<span>{$subjects.subjects?.[subjectKey({ type, id })]?.title}</span>import { useBlockController, useSupabase } from "better-supabase/solid";
import { rpcTransport } from "better-supabase/blocks";
import {
batchSubjects,
createSubjects,
createSubjectsController,
subjectKey,
} from "better-supabase/blocks/subjects";
export function SubjectTitle(props: { type: string; id: string }) {
const supabase = useSupabase();
const ref = { type: props.type, id: props.id };
const { state } = useBlockController(createSubjectsController, {
client: batchSubjects(
createSubjects({ transport: rpcTransport(supabase), schema: "api" }),
),
refs: [ref],
});
return <span>{state().subjects?.[subjectKey(ref)]?.title}</span>;
}Last updated on
App tables
Declare the tables your app owns in tables, and better-supabase upgrade writes them with the scope column, row level security, grants and a pgTAP test.
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.