# Related items panel

> The records linked to a record, with a picker to add or remove a link.

- Block: [relations](https://bettersupabase.com/blocks/relations.md)
- Kind: component
- Data: loaded on the server through the block's client
- Page: https://bettersupabase.com/recipes/related-items-panel

## Install the block

```bash
npx better-supabase add relations
```

## Web (Next.js)

Add the shadcn components:

```bash
npx shadcn add badge button card empty field select
```

From `apps/examples/nextjs/src/features/customers/components/related-items-panel.tsx` (https://github.com/ScaleDockHQ/better-supabase/blob/main/apps/examples/nextjs/src/features/customers/components/related-items-panel.tsx):

```tsx
/** Customers linked to this one (the `relates` kind), and a picker to link more. */
export function RelatedItemsPanel({
  customerId,
  relations,
  canManage,
}: {
  customerId: string;
  relations: Loaded<Relations>;
  canManage: boolean;
}) {
  const t = useExtracted("customers");

  return (
    <Card size="sm" data-recipe="related-items-panel">
      <CardHeader>
        <CardTitle>{t("Related customers")}</CardTitle>
        <CardDescription>
          {t("Link customers that belong together, such as a parent company.")}
        </CardDescription>
      </CardHeader>
      <CardContent className="flex flex-col gap-4">
        {relations.kind === "error" ? (
          <RecordLoadError title={t("The related customers didn't load")} />
        ) : (
          <>
            {relations.data.links.length === 0 ? (
              <Empty className="border">
                <EmptyHeader>
                  <EmptyMedia variant="icon">
                    <LinkIcon />
                  </EmptyMedia>
                  <EmptyTitle>{t("No related customers")}</EmptyTitle>
                  <EmptyDescription>
                    {canManage
                      ? t("Pick a customer below to link it.")
                      : t("Nobody linked a customer yet.")}
                  </EmptyDescription>
                </EmptyHeader>
              </Empty>
            ) : (
              <RelatedList
                customerId={customerId}
                links={relations.data.links}
                canManage={canManage}
              />
            )}
            {canManage ? (
              <LinkForm
                customerId={customerId}
                options={relations.data.options.filter(
                  (option) =>
                    !relations.data.links.some(
                      (link) =>
                        link.subjectType === "customer" &&
                        link.subjectId === option.id,
                    ),
                )}
              />
            ) : null}
          </>
        )}
      </CardContent>
    </Card>
  );
}

function RelatedList({
  customerId,
  links,
  canManage,
}: {
  customerId: string;
  links: readonly RelatedCustomer[];
  canManage: boolean;
}) {
  const t = useExtracted("customers");
  const errorMessage = useErrorMessage();

  const unlink = useAction(unlinkCustomer, {
    onSuccess: () => {
      toast.success(t("Link removed"));
    },
    onError: (error) => {
      toast.error(errorMessage(error));
    },
  });

  return (
    <ul className="flex flex-col divide-y" data-testid="related-customers">
      {links.map((link) => (
        <li
          key={link.id}
          className="flex items-center justify-between gap-2 py-2"
        >
          <div className="flex min-w-0 items-center gap-2">
            {link.subjectType === "customer" && link.name !== null ? (
              <Link
                href={`/customers/${link.subjectId}`}
                className="truncate text-sm font-medium underline-offset-4 transition-colors hover:underline"
              >
                {link.name}
              </Link>
            ) : (
              <span className="truncate text-sm text-muted-foreground">
                {t("A record you can't open")}
              </span>
            )}
            {link.kind === "relates" ? null : (
              <Badge variant="outline">{link.kind}</Badge>
            )}
          </div>
          {canManage ? (
            <Button
              size="icon-sm"
              variant="ghost"
              aria-label={t("Remove link")}
              disabled={unlink.pendingInputs.some(
                (input) =>
                  !(input instanceof FormData) &&
                  input.subjectId === link.subjectId,
              )}
              onClick={() => {
                void unlink.run({
                  customerId,
                  kind: link.kind,
                  subjectType: link.subjectType,
                  subjectId: link.subjectId,
                });
              }}
            >
              <UnlinkIcon />
            </Button>
          ) : null}
        </li>
      ))}
    </ul>
  );
}

function LinkForm({
  customerId,
  options,
}: {
  customerId: string;
  options: readonly { readonly id: string; readonly name: string }[];
}) {
  const t = useExtracted("customers");
  const fieldId = useId();
  const errorMessage = useErrorMessage();
  const [targetId, setTargetId] = useState<string | null>(null);

  const link = useAction(linkCustomer, {
    onSuccess: () => {
      setTargetId(null);
      toast.success(t("Customer linked"));
    },
  });

  const items = options.map((option) => ({
    value: option.id,
    label: option.name,
  }));

  if (items.length === 0) {
    return null;
  }

  return (
    <form
      className="flex items-end gap-2"
      onSubmit={(event) => {
        event.preventDefault();

        if (targetId) {
          void link.run({ customerId, targetId });
        }
      }}
    >
      <Field data-invalid={link.error ? true : undefined}>
        <FieldLabel htmlFor={`${fieldId}-related`}>
          {t("Link a customer")}
        </FieldLabel>
        <Select
          value={targetId}
          items={items}
          onValueChange={(value) => {
            setTargetId(value);
          }}
        >
          <SelectTrigger id={`${fieldId}-related`} className="w-full">
            <SelectValue placeholder={t("Pick a customer")} />
          </SelectTrigger>
          <SelectContent>
            {items.map((item) => (
              <SelectItem key={item.value} value={item.value}>
                {item.label}
              </SelectItem>
            ))}
          </SelectContent>
        </Select>
        {link.error ? (
          <FieldError>{errorMessage(link.error)}</FieldError>
        ) : null}
      </Field>
      <Button
        type="submit"
        variant="outline"
        disabled={!targetId || link.pending}
      >
        {t("Link")}
      </Button>
    </form>
  );
}

/** The related customers card while its rows load. */
export function RelatedItemsPanelSkeleton() {
  return <RecordCardSkeleton rows={2} />;
}
```

## Prompt

Paste this into a coding agent to build the recipe in your app.

### Web

```text
Build "Related items panel" in a React web app that uses shadcn/ui components. The records linked to a record, with a picker to add or remove a link.

It uses the better-supabase `relations` block (Relations). It loads its data on the server through the block's client.

Steps:

1. If the app doesn't have the block yet, add the SQL with `npx better-supabase add relations` and apply the migration.
2. Add the shadcn components the recipe uses: `npx shadcn add badge button card empty field select`.
3. The recipe needs no glue: it calls the block's client on the server.
4. Write the component. Load the data on the server with the block's client, starting from the recipe's snippet (`apps/examples/nextjs/src/features/customers/components/related-items-panel.tsx`). Adapt it to the app's existing components, styling and data loading instead of copying it as is.
5. Check the result against the recipe and the block's docs: it typechecks, it reads the same block data, and it handles the loading and empty states the snippet handles.

Read the full code first:

- Recipe: https://bettersupabase.com/recipes/related-items-panel.md
- Block docs: https://bettersupabase.com/docs/blocks/relations
```
