Skip to content
Documentation
Build your product

Build your first workspace feature

Add workspace notes with a real migration, validated action, protected page and tenant tests.

View Markdown

Result and prerequisites

This tutorial adds workspace notes to your own copy: verified members can read the latest 50 notes, and owners/admins can create them. It is an original tutorial extension, not a feature already installed in the starter. Later guides add a paid create operation and explicit REST/MCP read access to this same domain.

Use a clean source copy and a dedicated hosted test installation. The sample TypeScript is checked against this source snapshot; its SQL and live browser create/list path still need authorized hosted execution. Nothing in this documentation project installs the example in the main application.

Diagram · Workspace notes request path

Browser form → verified server action → user-scoped Supabase client → column grants and workspace RLS → workspace-owned note. The list uses the same selected workspace and never chooses a tenant from a URL alone.

File map

Download or copy each complete source block into the following destination in your local project:

SampleDestination
Migrationsupabase/migrations/20260916090000_workspace_notes.sql
Validation and typeslib/notes-schema.ts
Server actionapp/actions/notes.ts
Formcomponents/notes/note-form.tsx
Pageapp/dashboard/notes/page.tsx
Loadingapp/dashboard/notes/loading.tsx
Error boundaryapp/dashboard/notes/error.tsx

Choose a new timestamp later than your last migration if your project has advanced. Never rename or edit an already-applied migration. Keep the filenames referenced by imports exactly as shown in the destination column.

Add the migration and policies

supabase/migrations/20260916090000_workspace_notes.sql
-- Tutorial-only additive migration. Apply in a dedicated hosted test installation.
create table public.workspace_notes (
  id uuid primary key,
  org_id uuid not null references public.organizations(id) on delete cascade,
  created_by uuid references auth.users(id) on delete set null,
  body text not null check (char_length(btrim(body)) between 1 and 2000),
  created_at timestamptz not null default now()
);
create index workspace_notes_org_created_idx on public.workspace_notes(org_id, created_at desc, id);
alter table public.workspace_notes enable row level security;
revoke all on public.workspace_notes from public, anon, authenticated;
grant select on public.workspace_notes to authenticated;
grant insert(id, org_id, created_by, body) on public.workspace_notes to authenticated;
create policy notes_read on public.workspace_notes for select to authenticated
  using ((select private.is_org_member(org_id)));
create policy notes_create on public.workspace_notes for insert to authenticated
  with check ((select private.can_manage_org(org_id)) and created_by = (select auth.uid()));
-- No update/delete grants: this tutorial implements create/list only.

org_id is the tenant boundary. Deleting a workspace cascades to its notes. Deleting a creator leaves the workspace's note with a null author reference; choose a different retention rule only through a deliberate product decision and new migration. The body limit is enforced in SQL as well as the form.

Row-level security (RLS) decides which rows a signed-in account may access. Grants separately decide which operations and columns that role can use. private.is_org_member and private.can_manage_org already exist in the template and check a current verified account. The migration permits select and limited insert only; clients cannot rewrite ownership, timestamps or existing notes.

Run the installer check, then apply migrations through npm run kickstart:migrate -- --check and npm run kickstart:migrate on the authorized test target. Do not switch to Supabase CLI migration history. For an already deployed installation, the migration-only command uses its configured hosted project and does not restore local Auth or change its production origin.

Validate input and retry identity

lib/notes-schema.ts
import { z } from 'zod';
export const noteInput = z.strictObject({
  request_id: z.uuid(),
  org_id: z.uuid(),
  body: z.string().trim().min(1).max(2000),
});
export type Note = { id: string; body: string; created_at: string };
export type NoteState = { error?: string; success?: string };

The request UUID makes a retried submission identifiable. The organization UUID travels with the form so a stale tab retains its original workspace target. Both are validated on the server. The hidden field is a statement of intent, not authorization.

Create through the signed-in user

app/actions/notes.ts
'use server';
import { revalidatePath } from 'next/cache';
import { getWorkspace, requireUser } from '@/lib/auth';
import { noteInput, type NoteState } from '@/lib/notes-schema';

export async function createNote(_previous: NoteState, form: FormData): Promise<NoteState> {
  const parsed = noteInput.safeParse({ request_id: form.get('request_id'), org_id: form.get('org_id'), body: form.get('body') });
  if (!parsed.success) return { error: 'Enter a note of 1–2,000 characters.' };
  const { user, supabase } = await requireUser();
  const { org_id, request_id, body } = parsed.data;
  try {
    const { role } = await getWorkspace(org_id);
    if (role === 'member') return { error: 'Only owners and admins can create notes.' };
  } catch { return { error: 'Workspace access is no longer available.' }; }
  const result = await supabase.from('workspace_notes').insert({ id: request_id, org_id, created_by: user.id, body });
  if (result.error?.code === '23505') {
    const existing = await supabase.from('workspace_notes').select('id,body,created_by').eq('org_id', org_id).eq('id', request_id).maybeSingle();
    if (existing.error || existing.data?.created_by !== user.id || existing.data?.body !== body) return { error: 'This request was already used. Start a new note.' };
  } else if (result.error) return { error: 'The note could not be saved. Retry with the same request.' };
  revalidatePath('/dashboard/notes');
  return { success: 'Note saved.' };
}

requireUser() verifies the session. getWorkspace(org_id) verifies that the supplied target is one of this account's current workspaces. The database independently enforces the role and author. Ordinary application writes use the signed-in user's client; no elevated client is needed for this base recipe.

If an insert response is lost, the same request ID can be retried. A duplicate primary key counts as success only when the visible existing row has the same workspace, author and body. Reusing an ID for changed content returns an error. Other database errors return a safe retry message, not a raw query or provider payload.

Render the form and list

components/notes/note-form.tsx
'use client';
import { useActionState } from 'react';
import { createNote } from '@/app/actions/notes';
import type { NoteState } from '@/lib/notes-schema';
export function NoteForm({ workspaceId, requestId }: { workspaceId: string; requestId: string }) {
  const [state, action, pending] = useActionState<NoteState, FormData>(createNote, {});
  return <form action={action} className="grid gap-3 max-w-xl">
    <input type="hidden" name="org_id" value={workspaceId} />
    <input type="hidden" name="request_id" value={requestId} />
    <label htmlFor="note-body">New workspace note</label>
    <textarea id="note-body" name="body" required minLength={1} maxLength={2000} className="rounded-md border p-3" />
    <button type="submit" disabled={pending} className="rounded-md border px-4 py-2">{pending ? 'Saving…' : 'Save note'}</button>
    <p role="status" aria-live="polite">{state.error || state.success}</p>
  </form>;
}
app/dashboard/notes/page.tsx
import { randomUUID } from 'node:crypto';
import { getWorkspace, requireUser } from '@/lib/auth';
import { NoteForm } from '@/components/notes/note-form';
import type { Note } from '@/lib/notes-schema';
export default async function NotesPage() {
  const { organization, role } = await getWorkspace();
  const { supabase } = await requireUser();
  const { data, error } = await supabase.from('workspace_notes').select('id,body,created_at').eq('org_id', organization.id).order('created_at', { ascending: false }).order('id').limit(50);
  if (error) throw new Error('Notes could not be loaded. Check the notes migration and retry.');
  const notes = (data ?? []) as Note[];
  return <section className="grid gap-6 p-6">
    <header><h1 className="text-2xl font-semibold">Workspace notes</h1><p>Latest 50 notes for {organization.name}.</p></header>
    {role !== 'member' && <NoteForm key={organization.id} workspaceId={organization.id} requestId={randomUUID()} />}
    {notes.length ? <ul className="grid gap-3">{notes.map(note => <li key={note.id} className="rounded-md border p-4"><p className="whitespace-pre-wrap break-words">{note.body}</p><time className="text-xs text-muted-foreground" dateTime={note.created_at}>{note.created_at.slice(0, 10)}</time></li>)}</ul> : <p>No notes yet. An owner or admin can add the first one.</p>}
  </section>;
}

The list is explicitly bounded to 50 rows and labels that limit. For a larger product, add cursor pagination before representing it as a complete history. The form's workspace key resets local state when the selected workspace changes. A successful revalidation refreshes notes and supplies a fresh request ID. The pending button limits accidental duplicates; SQL uniqueness remains the retry guarantee.

Add the loading/error files from the map. The error boundary exposes reset() as a visible retry action. A query failure must not be rendered as “No notes yet,” because that would hide a broken migration or permission policy.

Add navigation

In components/dashboard/app-shell.tsx, import NotebookPen from lucide-react and add this item to the existing navigation array:

{ href: '/dashboard/notes', label: 'Notes', icon: NotebookPen }

Open the real /dashboard/notes route with a verified account. Members should see the list without a create form; an owner/admin should see both. The hidden form alone is insufficient: direct inserts from a member must still fail.

Test tenant and role boundaries

The transactional SQL fixture and target-guarded runner are included with the example. Copy them to tests/database/notes-isolation.sql and scripts/test-notes.mjs. The runner uses the existing Management API helper and requires HOSTED_TEST_PROJECT_REF to match the configured project.

npm run typecheck
npm run lint
node scripts/test-notes.mjs

Only run the last command on an authorized hosted test target after the base migration. It inserts fixture users/notes in one transaction and rolls them back. Verify A cannot read or insert into B; member writes fail; unverified accounts are denied; oversized/empty values fail; and existing ownership/timestamp columns cannot be altered.

In the browser, test a lost-response retry, two workspace tabs, membership removal and a failed save. Confirm that a forged target does not write anywhere. A TypeScript pass cannot prove RLS behavior; the hosted fixture remains a required acceptance step.

Customize deliberately

If you add editing, introduce both column grants and UPDATE policies with USING and WITH CHECK; preserve immutable tenant identity. If you add attachments, design storage paths and cleanup separately. If you make creation paid, close the direct database insert path as described in paid access. Continue with database boundaries.

Template 0.0.1 · September 2026 source snapshot