Detailed coding patterns for this project. AGENTS.md has the rules — this file has the examples. Read this before writing any new code.
The server client uses @supabase/ssr with the Next.js cookies() API. It is async
because cookies() returns a promise in Next.js 16.
// src/lib/supabase/server.ts
import { createServerClient } from "@supabase/ssr";
import { cookies } from "next/headers";
export async function createClient() {
const cookieStore = await cookies();
return createServerClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY!,
{
cookies: {
getAll() {
return cookieStore.getAll();
},
setAll(cookiesToSet) {
try {
cookiesToSet.forEach(({ name, value, options }) =>
cookieStore.set(name, value, options)
);
} catch {
// setAll is called from Server Components where cookies can't be set.
// This can be ignored if the proxy refreshes the session.
}
},
},
}
);
}Usage in a server component or route handler:
const supabase = await createClient();
const { data, error } = await supabase.from("pages").select("*");The browser client is synchronous — no await needed.
// src/lib/supabase/client.ts
import { createBrowserClient } from "@supabase/ssr";
export function createClient() {
return createBrowserClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY!
);
}Usage in a client component:
"use client";
import { createClient } from "@/lib/supabase/client";
import { useEffect, useState } from "react";
export function PageList() {
const [pages, setPages] = useState<Page[]>([]);
const supabase = createClient();
useEffect(() => {
supabase.from("pages").select("*").then(({ data }) => {
if (data) setPages(data);
});
}, [supabase]);
return <ul>{/* render pages */}</ul>;
}For server-only operations without a user session (cron jobs, webhooks), use the
admin client. It uses SUPABASE_SECRET_KEY (service role) and must never be
imported from client code.
import { createAdminClient } from "@/lib/supabase/admin";
const supabase = createAdminClient();
const { data, error } = await supabase.rpc("purge_old_trash");Supabase without generated types returns joined relations as unknown. Use the
helpers in src/lib/supabase/typed-queries.ts instead of inline as unknown as
casts. Each helper pairs a query builder (encapsulating the select string) with a
result caster (centralizing the type assertion).
import {
membersWithProfiles,
asMemberProfileRows,
} from "@/lib/supabase/typed-queries";
// Query builder — chain filters as usual
const { data, error } = await membersWithProfiles(supabase).eq(
"workspace_id",
workspaceId,
);
// Result caster — returns typed rows
const members = asMemberProfileRows(data).map((m) => ({
id: m.user_id,
display_name: m.profiles.display_name,
email: m.profiles.email,
avatar_url: m.profiles.avatar_url,
}));Available helpers: membersWithProfiles, membersWithProfileEmail,
membersWithWorkspace, membersWithWorkspaceSlug,
membersWithWorkspaceSlugPersonal, pageVisitsWithPages. When adding a new
join pattern, define the select string, row type, query builder, and result
caster together in typed-queries.ts.
Next.js 16 uses src/proxy.ts instead of src/middleware.ts. The proxy refreshes
the Supabase session on every request (except static assets and health checks).
// src/proxy.ts
import { updateSession } from "@/lib/supabase/proxy";
import { NextResponse, type NextRequest } from "next/server";
export async function proxy(request: NextRequest) {
if (
!process.env.NEXT_PUBLIC_SUPABASE_URL ||
!process.env.NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY
) {
return NextResponse.next();
}
return await updateSession(request);
}
export const config = {
matcher: [
"/((?!monitoring|_next/static|_next/image|favicon.ico|api/health|.*\\.(?:svg|png|jpg|jpeg|gif|webp)$).*)",
],
};The updateSession function in src/lib/supabase/proxy.ts creates a server client
that reads cookies from the request and writes refreshed cookies to the response.
It calls supabase.auth.getUser() to trigger the refresh.
@supabase/ssr's client-side combineChunks → getItem path calls
chunkedCookie.startsWith() without a typeof guard. During session teardown
(account deletion, auth expiry, HTTP 500 from Supabase), cookie values can be
null. Every getAll callback in every Supabase client creation site must apply
value ?? "" to prevent TypeError: Cannot read properties of null.
This applies to all three files:
src/lib/supabase/server.ts—cookieStore.getAll().map(…)src/lib/supabase/client.ts—document.cookieparsingsrc/lib/supabase/proxy.ts—request.cookies.getAll().map(…)
When adding a new Supabase client creation site, always include the null guard.
When a useCallback closes over async state (e.g. a resolved ID), putting that
state in the dependency array causes the callback reference to change whenever the
state resolves. If a useEffect depends on that callback, the effect re-runs,
creating a cascade of state transitions that race with each other.
Pattern: pass async state as a parameter to a stable callback (empty deps). The effect that calls the callback includes the async state in its own deps so it re-runs when the state resolves.
When an effect starts async work (fetch, timer callback), the effect may re-run before the async work completes. The stale callback must not update state.
Use a cancelled flag, not a generation counter. A generation counter
(if (genRef.current !== gen) return) has a subtle race: if the effect re-runs
between the fetch start and the finally block, the counter check fails and the
status transition is skipped — leaving the UI stuck (issues #118–#192). A boolean
cancelled flag set once in cleanup is immune to this race.
// ❌ Bad — generation counter race in finally block
const gen = ++genRef.current;
fetch(url, { signal }).finally(() => {
if (genRef.current === gen) setStatus("done"); // skipped if effect re-ran
});
// ✅ Good — cancelled flag set once in cleanup
let cancelled = false;
fetch(url, { signal })
.then(res => { if (!cancelled) { /* update state */ } })
.catch(err => { if (!cancelled) { /* handle error */ } });
// cleanup:
return () => { cancelled = true; controller.abort(); };Also: always add .catch() to fire-and-forget promises in effects. An unhandled
rejection silently prevents state transitions, leaving the UI stuck.
All errors must reach Sentry. console.error alone is never sufficient — always
pair with a Sentry capture call. Bare catch {} is banned except for documented
exceptions (see allowlist below).
Every Supabase mutation must check the error return and call captureSupabaseError:
import { captureSupabaseError } from "@/lib/sentry";
const { error } = await supabase.from("pages").update({ title }).eq("id", pageId);
if (error) {
captureSupabaseError(error, "pages.update");
// handle the error (toast, return error response, etc.)
}The helper accepts PostgrestError (from query results) and generic Error (from
catch blocks). It tags the Sentry event with the operation name, error code, and
message so errors are filterable in the Sentry dashboard.
Transient network errors are automatically captured at warning level instead of
error — they are not application bugs and should not trigger error-level alerts.
Browser-style messages: TypeError: Failed to fetch, Failed to fetch,
Load failed, NetworkError when attempting to fetch resource.,
The Internet connection appears to be offline., Network request failed.
The Supabase client may append the hostname in parentheses, e.g.
TypeError: Failed to fetch (example.supabase.co) — use startsWith matching,
not exact equality, for the TypeError: Failed to fetch pattern.
Node.js native fetch (undici) messages: fetch failed or TypeError: fetch failed
(top-level), with the real cause wrapped in error.cause — look for ECONNRESET,
ENOTFOUND, ETIMEDOUT, UND_ERR_SOCKET in the cause message. When Supabase
wraps a Node.js fetch error as a PostgrestError, the message becomes
"TypeError: fetch failed" and the cause chain (ECONNRESET etc.) is embedded in
the details string rather than error.cause. Always check both error.cause
and details for server-side fetch errors, not just the top-level message.
Never call lazyCaptureException or Sentry.captureException directly for errors
originating from Supabase queries or mutations. Always use captureSupabaseError
which classifies transient network errors, schema-not-found errors, and RLS
violations at warning level. Direct capture bypasses this classification and floods
Sentry with error-level noise for non-application-bugs.
// ✅ Correct — uses captureSupabaseError
captureSupabaseError(error, "editor:save");
// ❌ Wrong — bypasses error classification
lazyCaptureException(error);Reserve lazyCaptureException for errors that have no structured classification
(e.g. Lexical editor framework errors, unexpected runtime exceptions).
The Supabase PostgREST client (v2.103.0+) returns plain objects
{ message, details, hint, code } — not PostgrestError class instances — when
fetch throws a network error in the default (non-throwOnError) mode. The
PostgrestError class is only instantiated when shouldThrowOnError is true.
Do NOT use instanceof Error to check for Supabase errors. Use duck-type checks:
// ✅ Correct — works for both PostgrestError instances and plain objects
if (error && typeof error === "object" && "code" in error && "details" in error) { ... }
// ❌ Wrong — fails for plain objects from network errors
if (error instanceof Error && "code" in error) { ... }captureSupabaseError handles this automatically — it wraps plain objects in a
proper Error before sending to Sentry so they get proper stack traces and
grouping. The isPostgrestError duck-type check in src/lib/sentry/postgrest-errors.ts also
handles both shapes.
Null-safety for PostgREST fields: PostgREST error responses may return
details: null and hint: null (e.g. PGRST500 server errors). The in operator
in isPostgrestError returns true when the key exists regardless of value type.
Always null-coalesce when reading these fields:
// ✅ Correct — handles null from PostgREST 500 responses
const details = isPostgrestError(error) ? (error.details ?? "") : "";
// ❌ Wrong — crashes with TypeError: null.startsWith() / null.includes()
const details = isPostgrestError(error) ? error.details : "";Non-Supabase fetch calls (e.g. /api/pages/…/versions) must use captureApiError
from @/lib/sentry, not lazyCaptureException. This classifies transient network
errors (TypeError: Failed to fetch, Load failed) at warning level.
import { captureApiError } from "@/lib/sentry";
// ✅ Correct — classifies transient network errors
captureApiError(error, "versions:fetch");
// ❌ Wrong — bypasses transient network classification
lazyCaptureException(error);Client-side Supabase queries that run on page load (e.g. workspace lookup, page
list fetch) and server-side API route RPC/query calls should use
retryOnNetworkError from @/lib/retry to retry on transient network failures
before reporting to Sentry:
import { retryOnNetworkError } from "@/lib/retry";
const { data, error } = await retryOnNetworkError(() => {
const supabase = createClient();
return supabase.from("workspaces").select("id").eq("slug", slug).maybeSingle();
});The utility handles both failure modes:
- Result-level errors:
{ data: null, error: PostgrestError }where the error is a transient network failure. - Thrown errors: Node.js undici
TypeError: fetch failedthat bypasses the Supabase result pattern entirely (common in serverless environments).
This retries up to 2 times with exponential backoff (500ms, 1s). Only transient network errors are retried — application errors (RLS violations, constraint errors) are returned/thrown immediately. Do not wrap user-initiated mutations (create, update, delete) in retry — those should fail fast and show a toast.
When a table has multiple foreign keys to the same target table, PostgREST cannot
infer which relationship to use. The query silently returns null data (no error
thrown). Disambiguate by specifying the FK constraint name:
// BAD — ambiguous: members has both user_id and invited_by referencing profiles
const { data } = await supabase
.from("members")
.select("*, profiles(email, display_name)");
// data will be null with a PGRST201 error
// GOOD — specify the FK constraint
const { data } = await supabase
.from("members")
.select("*, profiles!members_user_id_fkey(email, display_name)");Check supabase/migrations/ for constraint names. The naming convention is
{table}_{column}_fkey.
All catch blocks in API routes must use captureApiError (not raw
Sentry.captureException). captureApiError classifies transient network
errors (TypeError: fetch failed, Failed to fetch) at warning level,
preventing them from creating noise in Sentry. Filter out expected
authorization errors before reporting:
import { captureApiError, isInsufficientPrivilegeError } from "@/lib/sentry";
try {
// ... route logic
} catch (error) {
if (error instanceof Error && isInsufficientPrivilegeError(error)) {
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
}
captureApiError(error, "route-name:operation");
return NextResponse.json({ error: "Internal server error" }, { status: 500 });
}Never use Sentry.captureException(error) directly in API route catch blocks —
it bypasses transient network error classification and reports all errors at
error level.
A structural test (src/lib/sentry/api-route-consistency.test.ts) scans all API
route files and verifies that routes performing Supabase mutations import
isForeignKeyViolationError or captureSupabaseError, and that catch blocks use
captureApiError or captureSupabaseError instead of bare console.error. When
adding a new API route with mutations, the test will fail if the error-handling
pattern is missing. If a route intentionally skips a check, add it to the
allowlist in the test file with a comment explaining why.
request.json() throws a SyntaxError when the body is empty or malformed JSON.
This is a client error, not an application bug — it must return 400 without reaching
the generic catch block (which would report it to Sentry at error level).
Wrap request.json() in its own try-catch before the main route logic:
let body: { content: Record<string, unknown> | null };
try {
body = await request.json() as typeof body;
} catch (_e) {
// Malformed/empty body is a client error — return 400 without Sentry capture
return NextResponse.json(
{ error: "Invalid JSON in request body" },
{ status: 400 },
);
}Use catch (_e) (not bare catch {}) to satisfy the static analysis convention.
Client-side mutations must show toast.error() on failure in addition to Sentry
capture:
import { captureSupabaseError } from "@/lib/sentry";
import { toast } from "sonner";
const { error } = await supabase.from("pages").insert({ ... });
if (error) {
captureSupabaseError(error, "pages.insert");
toast.error("Failed to create page", { duration: 8000 });
}These files have intentional bare catch {} blocks with documented reasons:
src/lib/supabase/server.ts— cookiesetAllin Server Components (can't set cookies, safe to ignore)src/components/editor/editor.tsx— URL validation (new URL()throws on invalid input)src/app/api/health/route.ts— intentionally silent, monitored by Performance Monitor
All other catch blocks must capture the error variable and report to Sentry.
E2E tests (Playwright with HeadlessChrome) can trigger server-side errors that are not application bugs. Both client-side and server-side Sentry configs must filter these out:
-
Client-side —
isE2ETestSession()uses two synchronous checks:window.__SENTRY_DISABLED__flag set by Playwright'saddInitScript.navigator.userAgentcontainingHeadlessChrome/— fallback that works even if the flag hasn't been set yet during early page load. Both checks are synchronous, eliminating race conditions with async Sentry initialization (the SDK is loaded via dynamicimport()ininstrumentation-client.ts).
-
Server-side —
sentry.server.config.tsandsentry.edge.config.tsuseisE2ETestRequest(event)which checks four sources:event.request.headersforHeadlessChrome/in the User-Agent — works for unhandled exceptions where Sentry auto-attaches request context.event.contexts.browser.nameforHeadlessChrome— fallback for manually captured exceptions (viacaptureException/lazyCaptureException) whereevent.requestis empty but the SDK enriches browser context.event.extra.userAgentforHeadlessChrome/— fallback for errors captured viacaptureSupabaseError/captureApiErrorwhich forward the request UA.- Isolation scope's
sdkProcessingMetadata.normalizedRequest.headers— fallback foron_request_errorevents (Next.jscaptureRequestError) whereevent.requestis null andevent.contexts.browseris only populated by Sentry's ingestion pipeline afterbeforeSendruns.
All four checks are needed because different capture mechanisms populate different fields. Always check all paths when filtering E2E test noise.
When adding new Sentry config files or beforeSend filters, use the
consolidated filter functions: shouldDropClientEvent for client-side and
shouldDropServerEvent for server-side/edge. These combine all noise filters
(transient network errors, auth lock contention, Next.js internal noise, E2E
test sessions) into a single call. When adding a new noise filter, add it to
the appropriate consolidated function so all runtimes stay in sync.
Route handlers and server utilities that use Supabase must guard against missing
env vars before calling createClient(). Without the guard, createServerClient
receives undefined and throws, which can crash the route or produce misleading
error responses (e.g., health endpoint reporting "down" instead of "not configured").
The proxy already does this — route handlers must follow the same pattern:
if (
!process.env.NEXT_PUBLIC_SUPABASE_URL ||
!process.env.NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY
) {
// Return a graceful response instead of crashing
return NextResponse.json({ status: "ok", db: { connected: false, reason: "not_configured" } });
}Apply this guard in any route handler that calls createClient() and must remain
functional even when Supabase is not yet configured (e.g., health checks, public
status endpoints).
Server components are the default. They are async functions with named exports
(except page.tsx and layout.tsx which use default exports per Next.js convention).
// src/app/page.tsx — landing page (server component, no "use client")
export default function Home() {
return (
<main className="flex min-h-screen flex-col items-center justify-center p-8">
<div className="max-w-2xl text-center space-y-6">
<h1 className="text-5xl font-bold tracking-tight text-zinc-900 dark:text-zinc-50">
Memo
</h1>
{/* ... */}
</div>
</main>
);
}Use "use client" only for hooks, event handlers, or browser APIs.
// src/app/global-error.tsx — needs useEffect and Sentry browser SDK
"use client";
import * as Sentry from "@sentry/nextjs";
import NextError from "next/error";
import { useEffect } from "react";
export default function GlobalError({
error,
}: {
error: Error & { digest?: string };
}) {
useEffect(() => {
Sentry.captureException(error);
}, [error]);
return (
<html>
<body>
<NextError statusCode={0} />
</body>
</html>
);
}- Fetching and displaying data → server component
- Forms, buttons, interactive UI → client component
- Layout, navigation structure → server component
- Real-time subscriptions → client component
Theme is managed by src/lib/theme.tsx which provides ThemeProvider and useTheme.
- Storage:
localStorage("memo-theme")— values:"light","dark","system". - Mechanism:
data-theme="light|dark"attribute on<html>, plus.darkclass for shadcn. - Flash prevention: Inline
<script>in<head>reads localStorage before React hydrates. - System detection:
prefers-color-schememedia query listener when preference is"system". - Default:
"dark"(existing users who haven't set a preference get dark mode). - localStorage safety: All
localStoragecalls must be wrapped in try-catch. Browsers may throwSecurityErrorwhen storage access is denied (privacy settings, embedded contexts, enterprise policies). Fall back to"dark"on read, silently ignore on write.
// Reading theme in a client component
import { useTheme } from "@/lib/theme";
function MyComponent() {
const { preference, resolved, setPreference } = useTheme();
// preference: "light" | "dark" | "system"
// resolved: "light" | "dark" (actual applied theme)
}Rules:
- Never hardcode
white/[0.xx]orblack/[0.xx]— use overlay/label tokens. - Use
bg-overlay-hoverinstead ofbg-white/[0.04]. - Use
border-overlay-borderinstead ofborder-white/[0.06]. - Use
text-label-faintinstead oftext-white/30. - The Toaster in
providers.tsxreadsresolvedtheme to pass to Sonner. - Storybook has a toolbar theme switcher — stories render in both themes.
- Components:
kebab-case.tsx(e.g.,page-list.tsx) - Utilities:
kebab-case.ts(e.g.,format-date.ts) - All exports are named exports. No default exports.
- One component per file.
- Exception: Next.js pages (
page.tsx), layouts (layout.tsx), and error boundaries (global-error.tsx) use default exports as required by the framework.
Route handlers use NextResponse.json() with structured error handling:
// src/app/api/health/route.ts — uses direct fetch to PostgREST,
// bypassing the Supabase JS client to avoid cold-start overhead.
import { NextResponse } from "next/server";
const SUPABASE_URL = process.env.NEXT_PUBLIC_SUPABASE_URL;
const SUPABASE_KEY = process.env.NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY;
const TIMEOUT_MS = 2000;
export async function GET() {
if (!SUPABASE_URL || !SUPABASE_KEY) {
return NextResponse.json({
status: "ok",
db: { connected: false, latency_ms: 0, reason: "not_configured" },
timestamp: new Date().toISOString(),
});
}
let dbStatus = "ok";
let dbLatency = 0;
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), TIMEOUT_MS);
const start = Date.now();
try {
const res = await fetch(`${SUPABASE_URL}/rest/v1/rpc/health_check`, {
method: "POST",
headers: {
apikey: SUPABASE_KEY,
Authorization: `Bearer ${SUPABASE_KEY}`,
"Content-Type": "application/json",
},
body: "{}",
signal: controller.signal,
});
dbLatency = Date.now() - start;
if (!res.ok) dbStatus = "degraded";
} catch (err) {
dbLatency = Date.now() - start;
dbStatus = err instanceof DOMException && err.name === "AbortError"
? "degraded" : "down";
} finally {
clearTimeout(timer);
}
return NextResponse.json({
status: dbStatus === "down" ? "down" : "ok",
db: { connected: dbStatus !== "down", latency_ms: dbLatency },
timestamp: new Date().toISOString(),
});
}Pattern: wrap in try/catch, return structured JSON with appropriate status codes.
For latency-sensitive endpoints, use direct fetch to PostgREST instead of the
Supabase JS client to avoid GoTrue/Realtime initialization on serverless cold starts.
API route handlers that use the Supabase client must check that env vars are present
before calling createClient(). The non-null assertion (!) on process.env values
does not throw when the value is undefined — it silently passes undefined to the
Supabase client, which then throws at request time.
// ✅ Correct — guard before using the client
if (!process.env.NEXT_PUBLIC_SUPABASE_URL || !process.env.NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY) {
return NextResponse.json({ error: "Supabase not configured" }, { status: 503 });
}
const supabase = await createClient();
// ❌ Wrong — createClient() uses non-null assertions that don't protect against undefined
const supabase = await createClient(); // throws at request time if env vars are missingThe proxy (src/proxy.ts) already follows this pattern. All API routes must do the same.
Use withRateLimit from @/lib/rate-limit to wrap API route handlers. The wrapper
intercepts requests before the handler runs and returns 429 with Retry-After header
when the limit is exceeded.
import { withRateLimit, getClientIp } from "@/lib/rate-limit";
// IP-based rate limiting (for public or semi-public endpoints)
async function handler(request: NextRequest) { /* ... */ }
export const POST = withRateLimit(handler, {
limit: 5,
windowMs: 60_000, // 1 minute
keyFn: (req) => `feedback:${getClientIp(req)}`,
});
// User-based rate limiting (for authenticated endpoints)
export const DELETE = withRateLimit(handler, {
limit: 3,
windowMs: 3_600_000, // 1 hour
keyFn: async () => {
const supabase = await createClient();
const { data } = await supabase.auth.getUser();
return data.user ? `account:${data.user.id}` : null; // null skips rate limiting
},
});In tests, mock the rate limiter as a passthrough to avoid interference:
vi.mock("@/lib/rate-limit", () => ({
withRateLimit: (handler: (...args: unknown[]) => unknown) => handler,
getClientIp: () => "127.0.0.1",
}));Note: the in-memory store resets on each serverless cold start. This protects against burst abuse within a single instance but not across instances. For production-grade protection, consider Vercel KV or Upstash Redis.
npx supabase migration new <descriptive-name>
# Creates: supabase/migrations/<YYYYMMDDHHmmss>_<name>.sql
# The CLI generates the correct UTC timestamp prefix automatically.-- supabase/migrations/20260414200000_add_pages_table.sql
create table pages (
id uuid primary key default gen_random_uuid(),
workspace_id uuid not null references workspaces(id) on delete cascade,
title text not null default '',
created_at timestamptz not null default now()
);
-- Always include RLS policies in the same migration
alter table pages enable row level security;
create policy "workspace members can read pages"
on pages for select
using (workspace_id in (
select workspace_id from members where user_id = auth.uid()
));Rules:
- Always use
npx supabase migration new— never create migration files manually. - One migration per logical change (table + its RLS policies together).
- Auto-applied on merge to main via the deploy-migrations CI workflow.
- Every FK referencing
profiles(id)orauth.users(id)must include anON DELETEclause (CASCADEorSET NULL). Omitting it causes thedelete_accountRPC to fail with a FK violation when a user deletes their account. - When adding a new table with a
user_idorcreated_byFK toprofilesorauth.users, also update thedelete_accountRPC (supabase/migrations/20260420150359_delete_account_rpc.sql) to explicitly handle the new table before the profile/auth deletion step. Relying solely on FK cascades is fragile — being explicit prevents future breakage.
Each test type has a specific purpose. Do not substitute one for another.
| Test type | Purpose | Examples |
|---|---|---|
| Unit (Vitest) | Pure logic, data transformations, utilities | database.test.ts (CRUD with mocked Supabase), formula.test.ts (parser/evaluator), page-tree.test.ts (tree operations) |
| Component (Vitest + jsdom) | Render components, verify callbacks, check state | Render PropertyTypePicker, click an option, verify onSelect fires with correct type |
| E2E (Playwright) | User interaction flows in a real browser | Create database, add column via type picker, edit a cell, verify value persists |
| Visual regression (Playwright) | Screenshot Storybook stories, compare baselines | Detect unintended visual changes to existing components |
| Static analysis (Vitest) | Structural convention enforcement only | "No bare catch blocks", "no @ts-ignore", migration naming |
Anti-pattern: source-grep tests for feature behavior. Do not write Vitest tests
that readFileSync source code and assert on string patterns. These verify
implementation details (variable names, import paths), not behavior. Use component
tests or E2E tests instead.
// ❌ Wrong — source-grep test for feature behavior
const source = readFileSync(resolve(__dirname, "./database-view-client.tsx"), "utf-8");
expect(source).toMatch(/PROPERTY_TYPE_LABEL\[type\]/);
// ✅ Correct — component test for the same behavior
render(<PropertyTypePicker onSelect={onSelect} />);
await userEvent.click(screen.getByRole("button", { name: /add column/i }));
await userEvent.click(screen.getByText("Date"));
expect(onSelect).toHaveBeenCalledWith("date");Source-grep tests are allowed only for convention enforcement:
// ✅ Allowed — convention enforcement
it("no bare catch blocks in source files", () => {
const files = glob.sync("src/**/*.ts");
for (const file of files) {
const source = readFileSync(file, "utf-8");
expect(source).not.toMatch(/catch\s*\{/);
}
});Any PR that adds or modifies components with user interactions must include E2E tests in the same PR. This is not optional — the PR Reviewer will block PRs that add interactive components without E2E coverage.
Interactive components include: buttons that trigger actions, dropdowns, dialogs, popovers, inline editing, drag-and-drop, keyboard shortcuts.
When a PR changes an existing interaction flow (e.g., replacing a plain button with
a dropdown, replacing window.prompt with a styled dialog), it must update all
affected E2E tests. Search before committing:
grep -r '<component-name>\|<selector>\|<old-text>' e2e/Storybook stories are not just for regression detection — they are the reference for what components should look like when rendered. Verification must compare rendered output in a browser, not just source code tokens.
Pre-merge verification (Feature Builder and PR Reviewer):
- Build and serve Storybook:
pnpm build-storybook && python3 -m http.server 6099 -d storybook-static & - Open new/modified stories in a browser viewport
- Visually verify the rendered output matches
.agents/design.md - Fix discrepancies before committing
- Run
pnpm test:visualto generate/update baselines - Clean up:
kill $(lsof -ti:6099) 2>/dev/null; rm -rf storybook-static
Post-merge verification (UI Verifier):
- Screenshot Storybook stories for changed components
- Screenshot the corresponding live site pages
- Compare Storybook rendering against live site rendering
- Flag integration-level discrepancies (component works in isolation but breaks in page context)
This catches bugs that static code review misses:
- Layout constraints from parent containers (e.g.,
max-w-3xlon database views) - CSS cascade issues where page-level styles override component styles
- Missing component wiring (e.g., registry editors not used in table view)
- Portal/overlay interactions that only manifest in the full page context
- Unit tests go next to the file they test:
foo.test.tsalongsidefoo.ts - Use Vitest:
import { describe, it, expect } from 'vitest' - API route tests: test the route handler directly
- Skip tests for components that are just layout/styling with no logic
E2E tests live in e2e/ at the project root. Config: playwright.config.ts.
// Unauthenticated test — uses base Playwright test
import { test, expect } from "@playwright/test";
test("sign-in page renders", async ({ page }) => {
await page.goto("/sign-in");
await expect(page.locator('input[type="email"]')).toBeVisible();
});// Authenticated test — uses the auth fixture
import { test, expect } from "./fixtures/auth";
test("editor loads on page", async ({ authenticatedPage: page }) => {
// authenticatedPage is already logged in
const editor = page.locator('[contenteditable="true"]');
await expect(editor).toBeVisible({ timeout: 10_000 });
});- Prefer
getByRole,getByLabel,getByTextover CSS selectors - For editor elements:
[contenteditable="true"],[data-lexical-editor] - For drag handles:
.memo-draggable-block-menu - For floating UI:
[role="toolbar"],[role="option"] - Use
.filter({ hasText: ... })to narrow generic selectors
- One
describeblock per feature area - Use
test.beforeEachfor common navigation (e.g., opening a page) - Use
test.skip(true, "reason")when preconditions aren't met (no pages, no button) - Timeouts: 10s for page loads, 3s for UI elements, 2s for state changes
- All tests:
pnpm test:e2e - Single file:
pnpm test:e2e -- e2e/editor-drag.spec.ts - Authenticated tests require
TEST_USER_EMAILandTEST_USER_PASSWORDenv vars - Run before pushing:
pnpm lint && pnpm typecheck && pnpm test && pnpm test:e2e
Storybook uses @storybook/react-vite (not @storybook/nextjs — incompatible with Next.js 16).
Config lives in .storybook/. Stories are co-located with components: component.stories.tsx
next to component.tsx.
import type { Meta, StoryObj } from "@storybook/react";
import { ComponentName } from "./component-name";
const meta: Meta<typeof ComponentName> = {
title: "Category/ComponentName",
component: ComponentName,
tags: ["autodocs"],
};
export { meta as default };
type Story = StoryObj<typeof ComponentName>;
export const Default: Story = {};
export const WithVariant: Story = {
args: { variant: "destructive" },
};Key conventions:
export { meta as default }— named export pattern, consistent with project rules.- Title hierarchy:
UI/Button,Components/EmojiPicker,Auth/OAuthButtons,Design System/Tokens. - Cover: default state, all variants/sizes, disabled/error states, composition examples.
- For components that depend on Supabase or server context, create visual-only stories with mock data passed via args or decorators.
Use @storybook/test for play functions that test user interactions:
import { expect, userEvent, within } from "@storybook/test";
export const Interactive: Story = {
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
const trigger = canvas.getByRole("button", { name: /open/i });
await userEvent.click(trigger);
await expect(canvas.getByRole("dialog")).toBeVisible();
},
};Baselines live in e2e/visual-regression.spec.ts-snapshots/ (committed to repo).
The test in e2e/visual-regression.spec.ts screenshots every story at 1280×800.
# Run visual regression (requires Storybook served on port 6099)
pnpm build-storybook
python3 -m http.server 6099 -d storybook-static &
STORYBOOK_URL=http://localhost:6099 pnpm test:visual
# Regenerate baselines after intentional visual changes
STORYBOOK_URL=http://localhost:6099 pnpm test:visual --update-snapshots
# Clean up
kill $(lsof -ti:6099) 2>/dev/null; rm -rf storybook-static- Dev mode:
pnpm storybook(port 6006) - Build:
pnpm build-storybook(outputs tostorybook-static/, gitignored)
For feat or fix PRs, create (or find) a GitHub issue before opening the PR.
# Create the issue with status:in-progress so automations don't pick it up
gh issue create --title "Short description" \
--body "## Problem\n\n...\n\n## Acceptance Criteria\n\n- [ ] ..." \
--label "feature,status:in-progress"
# Reference it in the PR description
# First line of PR body: Closes #N- Use
status:in-progresson issues you are actively working on. - Use
status:backlogonly for issues intended for automation pickup (Feature Builder, Bug Fixer). - Never label an issue
status:backlogif you plan to work on it yourself — the Feature Builder or Bug Fixer may claim it first. - Chore PRs (metrics, docs, deps) do not require an issue.
- Use
ona-userlabel on PRs created via interactive Ona sessions — these skip the issue-reference requirement.
Create a GitHub Issue with sufficient detail for the Feature Builder or Bug Fixer to act on. Every issue entering the backlog must have:
- Description: what and why
- Acceptance Criteria: testable checkboxes
- Dependencies: explicit issue refs or "None"
- Technical Notes: relevant files, patterns, edge cases
- 3 labels: status + priority + type
- Create an issue with
status:backlogwhen you want an automation to do the work. - Create an issue with
status:in-progresswhen you're doing the work yourself. - Use
ona-useron the PR when working via an interactive Ona session with no issue.
- Feature Planner adds
needs-human+ questions to an insufficient issue. - User responds with the requested information.
- Needs-Human Requeue automation (every 30 min) detects the response and removes
needs-human. - Feature Planner re-triages on its next manual run.
For the full automation roster and workflow details, see .ona/skills/development-workflow/SKILL.md.
- Use
@/path alias for all project imports - Group imports: external packages → internal modules → types
- No barrel exports (index.ts re-exports)
- Strict mode enabled
- No
any— useunknownand narrow - No
@ts-ignore— fix the type instead - No
ascasts unless unavoidable (add a comment explaining why) - Prefer interfaces for object shapes, types for unions/intersections
MouseEvent.target, MouseEvent.relatedTarget, and FocusEvent.relatedTarget are
typed as EventTarget | null. They can be non-Node objects (e.g. when the mouse
leaves to a cross-origin iframe or the browser window). Never cast them with
as Node or as HTMLElement — use instanceof guards instead.
// ✅ Correct — instanceof guard before DOM API call
const target = event.relatedTarget;
if (target instanceof Node && container.contains(target)) { ... }
// ✅ Correct — instanceof guard before property access
const target = event.target;
if (!(target instanceof HTMLElement)) return;
target.closest(".menu");
// ❌ Wrong — unsafe cast, throws TypeError if target is not a Node
container.contains(event.target as Node)A static analysis test (node-contains-safety.test.ts) enforces this convention.
This project uses shadcn/ui v4 with the base-nova style, which uses @base-ui/react
primitives instead of Radix. Key differences from older shadcn:
base-ui's TooltipTrigger does NOT support asChild. Use the render prop instead:
// ✅ Correct — base-ui render prop
<TooltipTrigger
render={<Button variant="outline" disabled />}
>
Button label
</TooltipTrigger>
// ❌ Wrong — asChild does not exist on base-ui primitives
<TooltipTrigger asChild>
<Button>...</Button>
</TooltipTrigger>Never nest a DialogTrigger inside a DropdownMenuItem. Base UI's DialogTrigger
with render={<>{children}</>} wraps children in a fragment, which has no DOM node
for the click handler. Instead, use controlled dialog state:
// ✅ Correct — controlled dialog opened by menu item click
const [dialogOpen, setDialogOpen] = useState(false);
<DropdownMenu>
<DropdownMenuContent>
<DropdownMenuItem onClick={() => setDialogOpen(true)}>
Open dialog
</DropdownMenuItem>
</DropdownMenuContent>
<MyDialog open={dialogOpen} onOpenChange={setDialogOpen} />
</DropdownMenu>
// ❌ Wrong — DialogTrigger inside DropdownMenuItem never fires
<DropdownMenuItem onSelect={(e) => e.preventDefault()}>
<DialogTrigger render={<>{children}</>} />
</DropdownMenuItem>Base UI's MenuItem (used by ContextMenuItem and DropdownMenuItem) uses
onClick for item actions. The Radix-style onSelect prop does not exist on
Base UI primitives — it is silently ignored if passed, causing menu actions to
never fire.
// ✅ Correct — Base UI uses onClick
<ContextMenuItem onClick={() => handleAction("rename")}>
Rename
</ContextMenuItem>
// ❌ Wrong — onSelect is silently ignored by Base UI
<ContextMenuItem onSelect={() => handleAction("rename")}>
Rename
</ContextMenuItem>Buttons use @base-ui/react/button internally. The Button component accepts
ButtonPrimitive.Props & VariantProps<typeof buttonVariants>.
- Proxy layer (
src/lib/supabase/proxy.ts): optimistic redirect — unauthenticated users on non-public routes get redirected to/sign-in. Public routes:/,/sign-in,/sign-up,/invite/*. - Layout layer (
src/app/(app)/layout.tsx): authoritative check — server component callssupabase.auth.getUser()and redirects if no user. This is the security boundary.
After sign-in or sign-up, the client fetches the user's workspace membership to get
the workspace slug, then redirects to /{workspaceSlug}. The query joins members
with workspaces to get the slug in one call:
const { data: membership } = await supabase
.from("members")
.select("workspace_id, workspaces(slug)")
.eq("user_id", user.id)
.limit(1)
.maybeSingle();Pass display_name in signUp options so the handle_new_user trigger can use it:
await supabase.auth.signUp({
email,
password,
options: { data: { display_name: displayName } },
});Editor plugins live in src/components/editor/. Each plugin is a separate file with
a single named export. Plugins are composed inside the <LexicalComposer> in editor.tsx.
"use client";
import { useEffect } from "react";
import { useLexicalComposerContext } from "@lexical/react/LexicalComposerContext";
export function MyPlugin(): null {
const [editor] = useLexicalComposerContext();
useEffect(() => {
// Register listeners, commands, transforms
return () => { /* cleanup */ };
}, [editor]);
return null;
}Floating elements (toolbar, link editor, slash menu) use @floating-ui/react for
positioning and createPortal to render into the editor's anchor element:
import { createPortal } from "react-dom";
import { computePosition, offset, flip, shift } from "@floating-ui/react";
// Position relative to selection or DOM node
computePosition(virtualEl, floatingEl, {
placement: "top",
middleware: [offset(8), flip(), shift({ padding: 8 })],
}).then(({ x, y }) => {
floatingEl.style.left = `${x}px`;
floatingEl.style.top = `${y}px`;
});
// Render via portal into the editor's anchor div
return createPortal(<div>...</div>, anchorElem);Content auto-saves via debounced OnChangePlugin. The save flow:
OnChangePluginfires on every editor state change (selection changes ignored)- Serialize:
editorState.toJSON()→ compare with last saved JSON string - If changed, debounce 500ms, then write to
pages.contentvia Supabase client - Track save status:
"idle" | "saving" | "saved"— display below editor
- Register the node class in
editor.tsx→initialConfig.nodes - Add theme classes in
theme.tsif the node uses themed CSS classes - Add a
SlashCommandOptionentry inslash-command-plugin.tsx - If the block needs a Lexical plugin (e.g.,
ListPlugin), add it inside<LexicalComposer>
Custom block nodes extend ElementNode. Each node file exports the class, a $create*
factory, and a $is* type guard. Nodes must implement exportJSON() and importJSON()
for persistence. Use $applyNodeReplacement in factory functions.
export class MyNode extends ElementNode {
static getType(): string { return "my-node"; }
static clone(node: MyNode): MyNode { /* ... */ }
static importJSON(serialized: SerializedMyNode): MyNode { /* ... */ }
exportJSON(): SerializedMyNode { /* ... */ }
createDOM(): HTMLElement { /* ... */ }
updateDOM(): boolean { return false; }
}
export function $createMyNode(): MyNode {
return $applyNodeReplacement(new MyNode());
}
export function $isMyNode(node: LexicalNode | null | undefined): node is MyNode {
return node instanceof MyNode;
}DecoratorNodes render React components via decorate(). Used for rich blocks like
images that need interactive UI. The component receives the editor instance and node
key for updates.
Each custom block type has a paired plugin file that:
- Defines an
INSERT_*_COMMANDviacreateCommand() - Registers the command handler in a
useEffect - Optionally uses
registerMutationListenerfor DOM-level behavior (e.g., emoji rendering)
The slash command menu imports the command and dispatches it on selection.
Always wrap editor.dispatchCommand() in editor.update() when the command's
listener mutates state (e.g. TOGGLE_LINK_COMMAND, FORMAT_TEXT_COMMAND).
Calling dispatchCommand from a React event handler without editor.update()
can execute mutations in a read-only context if an editorState.read() is active
on the call stack. See Sentry MEMO-5.
// ✅ Correct — writable context guaranteed
const handleSave = () => {
editor.update(() => {
editor.dispatchCommand(TOGGLE_LINK_COMMAND, url);
});
};
// ❌ Wrong — may run in read-only context
const handleSave = () => {
editor.dispatchCommand(TOGGLE_LINK_COMMAND, url);
};A static analysis test (lexical-dispatch-safety.test.ts) enforces this pattern.
All @lexical/* packages are pinned to the same version (currently 0.31.0).
Always update them together to avoid version mismatches.
Markdown import/export uses @lexical/markdown with a shared transformer list defined
in src/components/editor/markdown-utils.ts. The MARKDOWN_TRANSFORMERS array must
stay in sync with the editor's registered node types — when adding a new block type,
add its transformer here too.
import { MARKDOWN_TRANSFORMERS } from "@/components/editor/markdown-utils";
import { $convertToMarkdownString, $convertFromMarkdownString } from "@lexical/markdown";
// Export: inside editor.getEditorState().read()
const markdown = $convertToMarkdownString(MARKDOWN_TRANSFORMERS);
// Import: inside editor.update() or via parseMarkdownToEditorState() for headless conversion
$convertFromMarkdownString(markdown, MARKDOWN_TRANSFORMERS, root, true);For headless conversion (no mounted editor), use parseMarkdownToEditorState(markdown)
which creates a temporary editor, runs the conversion, and returns SerializedEditorState.
When components outside <LexicalComposer> need access to the editor (e.g., page menu
for markdown export), pass an editorRef prop to the Editor component. The internal
EditorRefPlugin captures the editor instance into the ref.
const editorRef = useRef<LexicalEditor | null>(null);
<Editor editorRef={editorRef} ... />
// Later: editorRef.current?.getEditorState().read(() => { ... });Use sonner for toast notifications. The <Toaster> is mounted in the root layout.
import { toast } from "sonner";
toast.error("Something went wrong", { duration: 8000 });Per design spec: toasts use rounded-sm, position bottom-right. Only show toasts for
errors, async completions, and destructive actions with undo — not for routine actions.
For destructive operations (deleting rows, columns, pages), use a deferred deletion pattern instead of a confirmation dialog. This reduces friction while preventing accidental data loss.
import { toast } from "sonner";
// 1. Snapshot state for undo
const snapshot = currentItems.find((item) => item.id === targetId);
// 2. Optimistically remove from local state
setItems((prev) => prev.filter((item) => item.id !== targetId));
// 3. Start a deferred timer to persist the deletion
const timer = setTimeout(async () => {
pendingDeletions.current.delete(targetId);
const { error } = await deleteFromDatabase(targetId);
if (error) {
toast.error("Failed to delete", { duration: 8000 });
// Reload to restore consistent state
}
}, 5500); // slightly longer than toast duration
pendingDeletions.current.set(targetId, timer);
// 4. Show toast with undo action
toast("Item deleted", {
duration: 5000,
action: {
label: "Undo",
onClick: () => {
clearTimeout(timer);
pendingDeletions.current.delete(targetId);
if (snapshot) setItems((prev) => [...prev, snapshot]);
},
},
});Rules:
- Use
useRef<Map<string, ReturnType<typeof setTimeout>>>to track pending deletions. - Timer duration (5500ms) must exceed toast duration (5000ms) to prevent premature deletion.
- Each deletion gets its own toast — multiple sequential deletions work independently.
- On undo, restore the full snapshot (including related data like row values for columns).
- On timer expiry, persist to the database and handle errors with a reload fallback.
When a query requires features not available through the Supabase query builder
(e.g., ts_rank, ts_headline, complex joins with computed columns), create a
PostgreSQL function and call it via supabase.rpc().
// Calling an RPC function from a route handler
const { data, error } = await supabase.rpc("search_pages", {
query: "search term",
ws_id: workspaceId,
result_limit: 20,
});Rules:
- Define the function in a migration with
security definerandset search_path = ''. - Use
stablefor read-only functions,volatilefor mutations. - Keep the function focused — one purpose per function.
- Return a
table(...)type for multi-row results so the client gets typed arrays.
Every route segment under (app)/ should have an error.tsx that delegates to the
shared LazyRouteError wrapper. This defers loading of RouteError (which pulls in
lucide-react and Button) until an error actually occurs, keeping ~7 kB gzip out of
every page's first-load JS.
// src/app/(app)/[workspaceSlug]/error.tsx
"use client";
import { LazyRouteError } from "@/components/lazy-route-error";
export default function WorkspaceError({
error,
reset,
}: {
error: Error & { digest?: string };
reset: () => void;
}) {
return <LazyRouteError error={error} reset={reset} />;
}The RouteError component (src/components/route-error.tsx) handles Sentry reporting
and renders a centered error UI with a retry button. LazyRouteError
(src/components/lazy-route-error.tsx) wraps it with next/dynamic so the lucide icon
and Button are only loaded when an error boundary triggers. Route-level error.tsx files
are thin wrappers — all logic lives in RouteError.
Route pages that display named entities export generateMetadata to set the browser
tab title. The pattern fetches the entity name server-side:
import type { Metadata } from "next";
import { createClient } from "@/lib/supabase/server";
export async function generateMetadata({
params,
}: {
params: Promise<{ workspaceSlug: string }>;
}): Promise<Metadata> {
const { workspaceSlug } = await params;
const supabase = await createClient();
const { data } = await supabase
.from("workspaces")
.select("name")
.eq("slug", workspaceSlug)
.maybeSingle();
return { title: data?.name ? `${data.name} — Memo` : "Memo" };
}Rules:
- Suffix all titles with
— Memo. - Fall back to just
"Memo"if the entity is not found. - Use
maybeSingle()— never throw on missing data in metadata functions.
Each route segment under (app)/ has a loading.tsx that renders a skeleton matching
the shape of the page content. Skeletons use bg-muted animate-pulse with sharp corners.
Rules:
- Match the layout of the actual page (heading width, content rows, sidebar shape).
- Use varying widths on skeleton rows to look natural (e.g.
maxWidth: ${55 + ((i * 13) % 35)}%). - No rounded corners on skeletons — sharp edges per design spec.
- No loading spinners — skeletons only.
Custom not-found.tsx files provide contextual 404 messages:
- Root
not-found.tsx: full-screen centered, links to/. Uses inline SVG and plain<a>instead of lucide icons and Button/Link client components — Next.js embeds the notFound fallback in the RSC payload of every page, so client component imports here add to every route's first-load JS. (app)/not-found.tsx: within the app shell, mentions workspace/page access. Uses lucideFileQuestion+Button+Linksince the app layout already loads these.
Pattern: file-question icon (48px) + heading + description + "Go home" link.
When a feature depends on a database table that may not exist yet (e.g. migration not applied), the code must degrade silently instead of flooding Sentry with errors.
Pattern: check for PGRST205 (schema-not-found) before calling captureSupabaseError:
import { captureSupabaseError, isSchemaNotFoundError } from "@/lib/sentry";
const { data, error } = await supabase.from("optional_table").select("*");
if (error) {
// Table missing — degrade gracefully, don't report to Sentry
if (isSchemaNotFoundError(error)) return;
captureSupabaseError(error, "feature:operation");
return;
}Additionally, captureSupabaseError automatically downgrades PGRST205 errors to
warning level as a safety net, but callers should still skip the call entirely for
read operations on optional tables to avoid unnecessary noise.
When a .single() call returns 0 rows, PostgREST returns PGRST116 ("Cannot coerce
the result to a single JSON object"). This happens when the target row was deleted
between the user action and the lookup — a race condition during concurrent deletion
or E2E test teardown. The caller already handles the null result gracefully (returns
{ data: null, error }), so this is not an application bug.
captureSupabaseError automatically downgrades PGRST116 to warning level via
isEmptyResultError. No per-call-site changes are needed — all ~12 .single() calls
in database.ts benefit from the general classifier.
If a caller needs to distinguish "not found" from other errors (e.g. to return 404),
use isEmptyResultError before captureSupabaseError:
import { captureSupabaseError, isEmptyResultError } from "@/lib/sentry";
const { data, error } = await supabase.from("pages").select("*").eq("id", id).single();
if (error) {
if (isEmptyResultError(error)) {
return NextResponse.json({ error: "Not found" }, { status: 404 });
}
captureSupabaseError(error, "pages:lookup");
return NextResponse.json({ error: "Operation failed" }, { status: 500 });
}When an RPC uses RAISE EXCEPTION to reject callers who lack access (e.g.
non-members calling workspace-scoped functions), PostgreSQL returns error code
42501 (insufficient_privilege). This is an expected authorization check, not an
application bug — API routes must return 403 and must NOT report to Sentry.
Pattern: check for 42501 before calling captureSupabaseError:
import { captureSupabaseError, isInsufficientPrivilegeError } from "@/lib/sentry";
const { data, error } = await supabase.rpc("workspace_rpc", { ws_id: workspaceId });
if (error) {
if (isInsufficientPrivilegeError(error)) {
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
}
captureSupabaseError(error, "workspace_rpc");
return NextResponse.json({ error: "Operation failed" }, { status: 500 });
}Never return 500 for 42501 — it inflates Sentry error counts with non-bugs.
Supabase can throw RLS violations as exceptions (e.g. via .single() or async
rejection) instead of returning them in { data, error }. The outer catch
block in every API route must check for isInsufficientPrivilegeError before
falling through to captureApiError:
} catch (error) {
if (error instanceof Error && isInsufficientPrivilegeError(error)) {
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
}
captureApiError(error, "route-name:operation");
return NextResponse.json({ error: "Internal server error" }, { status: 500 });
}This prevents 42501 errors from being reported at error level in Sentry and ensures the client receives 403 instead of 500.
When a PostgreSQL RPC uses RAISE EXCEPTION '...' USING errcode = '42501',
Supabase may surface the error in two ways:
{ data, error }path — the error is a PostgrestError withcode: "42501"- Thrown exception — the error is a generic
Errorwith acodeproperty but withoutdetails/hint, so it fails theisPostgrestErrorduck-type check
isInsufficientPrivilegeError handles both shapes: it checks isPostgrestError
first, then falls back to checking for a code property with value "42501",
and finally checks the message for the RLS violation pattern. This ensures
custom RPC messages like "Not a member of this workspace" are correctly
identified as authorization errors in catch blocks.
Client-side Supabase operations (e.g. sidebar page-tree inserts) can also hit
RLS violations — typically when a user exceeds workspace limits. The user already
sees a toast error, so reporting to Sentry is pure noise. Skip
captureSupabaseError for both isSchemaNotFoundError and
isInsufficientPrivilegeError:
import {
captureSupabaseError,
isInsufficientPrivilegeError,
isSchemaNotFoundError,
} from "@/lib/sentry";
const { data, error } = await supabase.from("table").insert({ ... }).select().single();
if (error) {
if (!isSchemaNotFoundError(error) && !isInsufficientPrivilegeError(error)) {
captureSupabaseError(error, "feature:operation");
}
toast.error("Failed to do thing", { duration: 8000 });
return;
}This applies to any client-side mutation where the RLS rejection is an expected authorization boundary (workspace limits, non-member access) and the user is already notified via toast.
When a client inserts a row referencing a parent that was deleted between page
load and the mutation (e.g. creating a page in a workspace that was deleted
during E2E test teardown), PostgreSQL returns error code 23503
(foreign_key_violation). This is an expected race condition, not an
application bug — the user already sees a toast error.
captureSupabaseError automatically downgrades 23503 to warning level.
Client-side code that guards before calling captureSupabaseError should also
skip isForeignKeyViolationError to avoid reporting entirely:
import {
captureSupabaseError,
isForeignKeyViolationError,
isInsufficientPrivilegeError,
isSchemaNotFoundError,
} from "@/lib/sentry";
if (error) {
if (
!isSchemaNotFoundError(error) &&
!isInsufficientPrivilegeError(error) &&
!isForeignKeyViolationError(error)
) {
captureSupabaseError(error, "feature:operation");
}
toast.error("Failed to do thing", { duration: 8000 });
}When a user rapidly triggers the same mutation (e.g. double-clicking "add
property"), two identical inserts can race against a unique constraint before
React state updates. PostgreSQL returns error code 23505
(unique_violation). This is an expected race condition, not an application
bug.
Prevention: Use a useRef boolean guard to prevent concurrent calls in
event handlers that trigger inserts with auto-generated names:
const isAdding = useRef(false);
const handleAdd = useCallback(async () => {
if (isAdding.current) return;
isAdding.current = true;
try {
// ... insert logic
} finally {
isAdding.current = false;
}
}, [deps]);Safety net: captureSupabaseError automatically downgrades 23505 to
warning level via isDuplicateKeyError. Client-side code that guards before
calling captureSupabaseError can also skip isDuplicateKeyError to avoid
reporting entirely.
The Supabase client uses the Web Lock API to serialize auth token refresh.
When multiple concurrent requests race for the lock (e.g. parallel
favorites:check calls on page load), the loser gets an AbortError: Lock broken by another request with the 'steal' option. This is expected behavior,
not a bug.
Two error shapes exist:
- PostgrestError-like —
detailscontains the AbortError message. Passes throughcaptureSupabaseErrorwhich downgrades to warning level viaisSupabaseAuthLockError. - Unhandled rejection — Supabase auth internals throw
Lock "lock:sb-..." was released because another request stole it. Dropped bybeforeSendin all runtimes (client viashouldDropClientEvent, server/edge viashouldDropServerEvent) viaisSupabaseAuthLockContention.
For client-side code that checks errors before calling captureSupabaseError,
also skip isSupabaseAuthLockError:
import {
captureSupabaseError,
isInsufficientPrivilegeError,
isSchemaNotFoundError,
isSupabaseAuthLockError,
} from "@/lib/sentry";
if (error) {
if (
!isSchemaNotFoundError(error) &&
!isInsufficientPrivilegeError(error) &&
!isSupabaseAuthLockError(error)
) {
captureSupabaseError(error, "feature:operation");
}
}When a query or RPC exceeds the configured statement timeout, PostgreSQL returns
error code 57014. This typically happens during cascading deletes or heavy
operations on cold connections. It is a transient infrastructure issue, not an
application bug.
captureSupabaseError automatically downgrades 57014 to warning level via
isStatementTimeoutError. No caller-side skip is needed — the warning-level
classification is sufficient since statement timeouts are rare and worth tracking.
When calling internal API routes via fetch(), always parse the response body
defensively. The server may return non-JSON responses (e.g. 405 Method Not
Allowed with empty body, 502 Bad Gateway with HTML). Calling res.json()
directly throws SyntaxError on non-JSON bodies.
const res = await fetch("/api/endpoint", { method: "DELETE" });
let body: { ok?: boolean; error?: string };
try {
body = await res.json();
} catch (_e) {
body = { error: "Operation failed." };
}
if (!res.ok) {
setError(body.error ?? "Operation failed.");
return;
}This prevents SyntaxError: Unexpected end of JSON input from reaching the
outer catch block and being reported to Sentry as an application error.
Product analytics events are recorded via two modules — src/lib/track-event-server.ts
(server) and src/lib/track-event.ts (client). They are separate files to avoid
pulling next/headers into client bundles.
import { trackEvent } from "@/lib/track-event-server";
// Fire-and-forget — use `void` to silence the floating promise lint
void trackEvent("page.viewed", user.id, {
workspaceId: workspace.id,
pagePath: `/${workspaceSlug}/${pageId}`,
metadata: { page_id: page.id },
});trackEvent dynamically imports the Supabase server client. Errors are captured
in Sentry via captureSupabaseError but never thrown.
import { trackEventClient } from "@/lib/track-event";
// Pass the already-available Supabase browser client
trackEventClient(supabase, "page.created", userId, {
workspaceId,
metadata: { page_id: newPage.id, source: "sidebar" },
});trackEventClient is synchronous (returns void). It wraps the insert in
Promise.resolve() to handle Supabase's PromiseLike return type and attaches
.catch() for error capture.
- One
trackEvent/trackEventClientcall per action — no wrapping or middleware. - Place the call after the action succeeds (after error checks, before navigation).
- Include
workspaceIdwhen available. Include relevant entity IDs inmetadata. - Never
awaitthe tracking call — it must not block the user action. - When the Supabase client isn't already available (e.g.
handleExport), usegetClient().then(supabase => trackEventClient(...)).catch(() => {}).
Database operations live in src/lib/database.ts. All functions use the browser
Supabase client and return { data, error } tuples (matching the Supabase SDK
convention). Errors are reported via captureSupabaseError inside the function —
callers only need to check error and show a toast.
import { createDatabase, addRow, loadDatabase } from "@/lib/database";
// Create a database (page + default property + default view)
const { data, error } = await createDatabase(workspaceId, userId, "Tasks");
if (error) {
toast.error("Failed to create database", { duration: 8000 });
return;
}
// Load all data for a database view
const { data: db, error: loadError } = await loadDatabase(databaseId);
if (db) {
// db.properties, db.views, db.rows are ready
}Key patterns:
createDatabaseis pseudo-atomic: creates page → property → view, cleans up the page on partial failure.deleteViewprevents deleting the last view (returns an error).addRowlooks upworkspace_idfrom the database page automatically.updateRowValueuses upsert on the(row_id, property_id)unique constraint.loadDatabasefetches properties, views, and rows in parallel viaPromise.all, then loads row values in a single batch query and groups them client-side.- Position auto-calculation:
addProperty,addView, andaddRowquery the max position and increment. Reorder functions accept an ordered ID array. loadWorkspaceMembersfetches member profiles for a workspace. The parent component (database-view-client.tsx) injects the result intoproperty.config._membersforpersonandcreated_byproperties so renderers can resolve user IDs.
created_time, updated_time, and created_by are read-only property types that
derive values from page metadata (row.page.created_at, .updated_at, .created_by)
instead of row_values. The pattern:
- Registry entry has
Editor: null— signals read-only to view components. isComputedType(type)checks if a type is computed.buildComputedValue(type, page)creates a synthetic value object from page metadata.- View components (e.g.,
TableView) callbuildComputedValueand pass the result to the registryRendererinstead of reading fromrow.values[prop.id]. - For
created_by, the renderer readsproperty.config._members(same asPersonRenderer) to resolve user IDs to display names and avatars.
import { isComputedType, buildComputedValue, getPropertyTypeConfig } from "@/components/database/property-types";
// In a view component's row rendering:
const value = isComputedType(prop.type)
? buildComputedValue(prop.type, row.page)
: row.values[prop.id]?.value ?? {};
const config = getPropertyTypeConfig(prop.type);
if (config) {
<config.Renderer value={value} property={prop} />
}The table view virtualizes rows when the count exceeds 50 using @tanstack/react-virtual.
Below the threshold, rows render directly without a virtualizer.
Key patterns:
useVirtualizermanages which rows are in the DOM. Overscan of 10 rows in each direction prevents flicker during fast scrolling.- Each
TableRowreceivesgridTemplateColumnsand renders as its own CSS grid instead of usingdisplay: contents. This is required because virtualized rows are absolutely positioned within a height container. - When
gridTemplateColumnsis not provided,TableRowfalls back todisplay: contentsfor backward compatibility (e.g., Storybook stories that wrap rows in a parent grid). - Keyboard navigation (
table-navigation.ts) callsscrollToRowvia a ref on the grid element to scroll virtualized rows into view before focusing them. If the target cell is not in the DOM, it scrolls first and retries after a frame. ROW_HEIGHT_PXmaps row height settings to pixel values for the virtualizer'sestimateSize. These must stay in sync with the Tailwind height classes.
Never pass a callback that accepts optional business arguments directly to onClick.
The browser forwards the MouseEvent as the first argument, which gets misinterpreted
as the optional parameter.
// ❌ BAD — MouseEvent leaks as initialValues
<button onClick={onAddRow}>+ New</button>
// ✅ GOOD — arrow function discards the event
<button onClick={() => onAddRow()}>+ New</button>When a callback accepts optional structured data (e.g. initialValues?: Record<…>),
the receiving function should also guard against non-plain objects as defense-in-depth:
const safeValues =
initialValues &&
typeof initialValues === "object" &&
!Array.isArray(initialValues) &&
Object.getPrototypeOf(initialValues) === Object.prototype
? initialValues
: undefined;Use data-testid attributes on interactive elements that E2E tests need to target.
Prefix with the domain to avoid collisions:
| Domain | Prefix | Example |
|---|---|---|
| Database | db- |
db-sort-button, db-filter-bar, db-row-{id} |
| Editor | editor- |
editor-toolbar, editor-slash-menu, editor-image |
| Members | member- / invite- / pending-invite- |
members-list, member-row-{userId}, invite-form, pending-invite-list |
| Sidebar | sidebar- |
sidebar-tree, sidebar-search |
For parameterized IDs, use kebab-case: db-sort-rule-{index}, editor-slash-item-{name}.
Do not add data-testid to every element — only to elements that E2E tests select
and that lack a stable accessible role/label selector.
Per-page budget: 200 kB gzipped first-load JS. Framework baseline budget: 160 kB.
Both are enforced by pnpm test:bundle (scripts/check-bundle.mjs).
See docs/bundle-budget.md for the full chunk inventory, splitting strategy, and
guidelines for keeping pages within budget when adding new features.
Key patterns already in use:
- Sentry: dynamically imported in
instrumentation-client.ts - Supabase client: lazy-loaded via
getClient()insrc/lib/supabase/lazy-client.ts - Lexical editor: dynamically imported in page-view and landing-demo components
- Error boundaries: lazy-loaded via
src/components/lazy-route-error.tsx - Heavy UI components: dynamically imported via
next/dynamicin client wrappers
When adding a new client-side dependency, run pnpm build && pnpm test:bundle and
check the "Shared Chunk Analysis" output. If the framework baseline grew, the import
likely landed in the root layout or providers — use dynamic import instead.
Form error messages use role="alert" so screen readers announce them immediately.
All auth forms already follow this pattern.
{error && <p className="text-xs text-destructive" role="alert">{error}</p>}Apply this to any user-facing error message rendered conditionally after a form submission or validation failure.
globals.css includes a @media (prefers-reduced-motion: reduce) rule that sets
animation-duration: 0.01ms !important and animation-iteration-count: 1 !important
on all elements. Do not add CSS transitions or animations without verifying they
respect this rule. Tailwind's animate-* utilities are covered automatically.
Composite widgets (role="tree", role="listbox", role="toolbar") use the
roving tabindex pattern so the widget is a single Tab stop. Only the currently
focused item has tabindex="0"; all others have tabindex="-1".
Implementation pattern (see page-tree.tsx for a full example):
- State: maintain
focusedIdin the container component. - tabbableId: compute which item gets
tabindex="0"— thefocusedIdif set, otherwise the first visible item (so the widget is reachable via Tab). - onKeyDown on the container: handle Arrow keys, Home, End, Enter.
Call
setFocusedId(id)thenelement.focus()to move focus. - onFocus on the container (event delegation): sync
focusedIdwhen an item receives focus via click or programmatic.focus(). Useinstanceof HTMLElementguard — never caste.target as HTMLElement. - Focus ring: apply
ring-1 ring-accent outline-noneon the focused item. - Child items: receive
focusedId(for focus ring) andtabbableId(for tabIndex) as props. Inner interactive elements (buttons, links) usetabIndex={-1}.
Reference: WAI-ARIA Treeview pattern.
e2e/accessibility.spec.ts runs axe-core on key pages (sign-in, workspace home,
page editor, workspace settings, members, database table/board/calendar). New pages
or major layout changes should be added to this spec. Fix any critical or serious
violations before merging.
When you discover a new pattern that should be replicated, or an anti-pattern that should be avoided, add it here. The Automation Auditor may also propose additions.