# Record page

> One row as a page: its fields, the rows that reference it, and comments and tags once the table is a subject.

- Blocks: [comments](https://bettersupabase.com/blocks/comments.md), [tags](https://bettersupabase.com/blocks/tags.md)
- Kind: screen
- Data: loaded on the server through the block's client
- Page: https://bettersupabase.com/recipes/record-page

## Install the blocks

```bash
npx better-supabase add comments tags
```

## Web (Next.js)

Add the shadcn components:

```bash
npx shadcn add badge card skeleton
```

From `apps/examples/nextjs/src/features/projects/components/project-page.tsx` (https://github.com/ScaleDockHQ/better-supabase/blob/main/apps/examples/nextjs/src/features/projects/components/project-page.tsx):

```tsx
/**
 * Render inside `<Suspense>`: one project as a record page. Its fields, its
 * tasks through the `project_id` reference, and the comments and tags that
 * reach it because `projects` is a subject.
 */
export async function ProjectPage({
  params,
}: {
  params: Promise<{ id: string }>;
}) {
  const [{ id }, session] = await Promise.all([params, getSession()]);
  const organizationId = tenantOf(session);

  if (!organizationId) {
    notFound();
  }

  const [project, tasks, members, t, format, locale] = await Promise.all([
    settle(() => getProject(id)),
    settle(() => getTasks({ q: "", status: null, projectId: id })),
    settle(() => getMemberOptions(organizationId)),
    getExtracted("projects"),
    getFormatter(),
    getLocale(),
  ]);

  if (project.kind !== "ok" || tasks.kind !== "ok" || members.kind !== "ok") {
    return <ProjectLoadError title={t("The project didn't load")} />;
  }

  const record = project.data;

  if (record === undefined) {
    notFound();
  }

  const nameOf = (userId: string | null) =>
    userId === null
      ? t("Unassigned")
      : (members.data.find((member) => member.userId === userId)?.name ??
        userId);

  const subject = { type: PROJECT_SUBJECT, id: record.id };

  return (
    <div className="grid items-start gap-6 lg:grid-cols-3">
      <div className="flex flex-col gap-6 lg:col-span-2">
        <div className="flex flex-col gap-1">
          <h1 className="text-2xl font-semibold tracking-tight">
            {record.name}
          </h1>
          <p className="text-sm text-muted-foreground">
            {t("Created {date}", {
              date: format.dateTime(new Date(record.createdAt), {
                dateStyle: "long",
              }),
            })}
          </p>
        </div>

        <Card>
          <CardHeader>
            <CardTitle>{t("Tasks")}</CardTitle>
            <CardDescription>
              {t("{count, plural, one {# task} other {# tasks}}", {
                count: tasks.data.length,
              })}
            </CardDescription>
            <CardAction>
              <TaskFormDialog
                projects={[{ id: record.id, name: record.name }]}
                members={members.data}
                defaultProjectId={record.id}
              />
            </CardAction>
          </CardHeader>
          <CardContent>
            {tasks.data.length === 0 ? (
              <p className="text-sm text-muted-foreground">
                {t("No tasks yet. Add the first one with New task.")}
              </p>
            ) : (
              <ul className="flex flex-col divide-y">
                {tasks.data.map((task) => (
                  <li
                    key={task.id}
                    className="flex items-center justify-between gap-3 py-2"
                  >
                    <div className="flex min-w-0 flex-col">
                      <span className="truncate text-sm font-medium">
                        {task.title}
                      </span>
                      <span className="text-xs text-muted-foreground">
                        {nameOf(task.assigneeId)}
                        {task.dueOn
                          ? ` · ${t("due {date}", {
                              date: format.dateTime(
                                new Date(`${task.dueOn}T00:00:00Z`),
                                { dateStyle: "medium", timeZone: "UTC" },
                              ),
                            })}`
                          : null}
                      </span>
                    </div>
                    <TaskStatusBadge status={task.status} />
                  </li>
                ))}
              </ul>
            )}
          </CardContent>
        </Card>

        <Card data-recipe="comment-thread">
          <CardHeader>
            <CardTitle>{t("Comments")}</CardTitle>
          </CardHeader>
          <CardContent>
            <CommentThread
              organizationId={organizationId}
              subject={subject}
              authors={Object.fromEntries(
                members.data.map((member) => [member.userId, member.name]),
              )}
              canComment={can(session, "comments.create")}
            />
          </CardContent>
        </Card>
      </div>

      <div className="flex flex-col gap-6">
        <Card size="sm">
          <CardHeader>
            <CardTitle>{t("Details")}</CardTitle>
          </CardHeader>
          <CardContent>
            <dl className="grid grid-cols-[auto_1fr] gap-x-4 gap-y-2 text-sm">
              <dt className="text-muted-foreground">{t("Budget")}</dt>
              <dd>
                {formatMoney(
                  money(record.budgetMinor, record.currency),
                  locale,
                )}
              </dd>
              <dt className="text-muted-foreground">{t("Lead")}</dt>
              <dd>{nameOf(record.leadId)}</dd>
              <dt className="text-muted-foreground">{t("Open tasks")}</dt>
              <dd>
                <Badge variant="outline">
                  {tasks.data.filter((task) => task.status !== "done").length}
                </Badge>
              </dd>
            </dl>
          </CardContent>
        </Card>
        <Card size="sm" data-recipe="tag-input">
          <CardHeader>
            <CardTitle>{t("Tags")}</CardTitle>
          </CardHeader>
          <CardContent>
            <TagInput organizationId={organizationId} subject={subject} />
          </CardContent>
        </Card>
        <Link
          href={`/projects?view=board&project=${record.id}`}
          className="text-sm text-muted-foreground transition-colors hover:text-foreground"
        >
          {t("Open this project's board")}
        </Link>
      </div>
    </div>
  );
}
```

## Prompt

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

### Web

```text
Build "Record page" in a React web app that uses shadcn/ui components. One row as a page: its fields, the rows that reference it, and comments and tags once the table is a subject.

It uses the better-supabase `comments` block (Comments and activity) with `tags`. It loads its data on the server through the block's client.

Steps:

1. If the app doesn't have the blocks yet, add the SQL with `npx better-supabase add comments tags` and apply the migration.
2. Add the shadcn components the recipe uses: `npx shadcn add badge card skeleton`.
3. The recipe needs no glue: it calls the block's client on the server.
4. Write the screen. Load the data on the server with the block's client, starting from the recipe's snippet (`apps/examples/nextjs/src/features/projects/components/project-page.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/record-page.md
- Block docs: https://bettersupabase.com/docs/blocks/comments
```
