
Opinionated Nextjs Patterns
- 66 installs
- 191 repo stars
- Updated July 24, 2026
- pproenca/dot-skills
opinionated-nextjs-patterns is a Claude Code skill for ai & agent building. It helps solo builders move faster with AI-assisted coding.
Key points
- opinionated-nextjs-patterns
- AI & Agent Building
- AI-coding skill
Opinionated Nextjs Patterns by the numbers
- 66 all-time installs (skills.sh)
- +7 installs in the week ending Aug 4, 2026 (Skillselion tracking)
- Ranked #6,006 of 16,546 AI & Agent Building skills by installs in the Skillselion catalog
- Data as of Aug 4, 2026 (Skillselion catalog sync)
npx skills add https://github.com/pproenca/dot-skills --skill opinionated-nextjs-patternsAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 66 |
|---|---|
| repo stars | ★ 191 |
| Last updated | July 24, 2026 |
| Repository | pproenca/dot-skills ↗ |
How do I helps with ai & agent building tasks during ai-assisted development?
Helps with ai & agent building tasks during AI-assisted development.
Who is it for?
Best when you're working on ai & agent building and need structured help with opinionated-nextjs-patterns.
Skip if: Teams with no ai & agent building needs, or anyone wanting a generic chat assistant without this specific workflow.
When should I use this skill?
When you need to helps with ai & agent building tasks during ai-assisted development, or when opinionated-nextjs-patterns is a claude code skill for ai & agent building. it helps solo builders move faster with ai-assist
What you get
Structured output aligned to opinionated-nextjs-patterns: opinionated-nextjs-patterns; AI & Agent Building; AI-coding skill.
Files
Opinionated Next.js 16 Patterns
Implementation-pattern reference for Next.js 16 (App Router) codebases that want a single, opinionated architecture. Contains 50 rules across 8 categories, prioritised by execution-lifecycle cascade impact — authorization and (optional) tenant modeling first, then the request boundary, server fetching, mutations, client boundaries, architecture, and UI conventions.
The rules are backend-agnostic in principle but use Supabase as the concrete example. Each rule teaches the transferable idea (e.g. "authorize at the data layer", "read through a typed repository"); where the backend genuinely matters, a *Transferable:* note explains the pattern for other stores (Drizzle, Prisma). The structure is a Turbo monorepo with @app/* packages you own — built on canonical libraries (next-safe-action, @supabase/ssr, @tanstack/react-query, react-hook-form + zod, shadcn/ui, next-intl, pino), not a vendored starter kit.
When to Apply
Reach for these rules when:
- Writing new code — pages, layouts, server actions, route handlers,
proxy.ts, feature packages, client components, hooks, the data-access package, SQL/migrations, forms. - Reviewing a PR — authorization slips (privileged client without a guard, missing
'server-only'), waterfalls (sequential awaits, client-fetching server data), drift (hand-edited generated types, deep package imports, hardcoded i18n strings). - Refactoring — moving code between
apps/webandpackages/*, splitting actions and services, lifting'use client'boundaries, replacing raw queries with a typed data-access factory, swapping a backend behind the data-access package. - Designing a feature — choosing the right client (request-scoped vs privileged vs browser), deciding action vs route handler, planning the form/server-action contract, scoping a tenant (if multi-tenant).
- Onboarding — understanding why the codebase looks the way it does, with concrete, transferable examples.
Rule Categories by Priority
| Priority | Category | Impact | Prefix |
|---|---|---|---|
| 1 | Authorization & Data-Layer Access | CRITICAL | auth- |
| 2 | Multi-Tenancy (optional, SaaS only) | CRITICAL | tenant- |
| 3 | Request Boundary (Proxy) | HIGH | proxy- |
| 4 | Server-Side Data Loading | HIGH | server- |
| 5 | Mutations: Actions & Route Handlers | HIGH | mutate- |
| 6 | Client/Server Boundaries | MEDIUM-HIGH | client- |
| 7 | Architecture & Services | MEDIUM | arch- |
| 8 | Forms & UI Conventions | MEDIUM | ui- |
Quick Reference
1. Authorization & Data-Layer Access (CRITICAL)
- `auth-use-standard-server-client` — Use the request-scoped, auth-bound client; never default to the privileged one.
- `auth-gate-admin-client` — Authorize before constructing the service-role client.
- `auth-trust-rls-no-duplicate-checks` — Authorize once at the data layer; don't re-check in app code.
- `auth-use-sql-policy-helpers` — Centralize scoping predicates in reusable SQL policy helpers.
- `auth-use-require-user` — Centralize the auth gate in one
requireUser()helper. - `auth-server-only-imports` — Mark privileged modules with
import 'server-only'. - `auth-mfa-in-middleware` — Enforce MFA at the
proxy.tsboundary, not per-page.
2. Multi-Tenancy (CRITICAL — optional, SaaS only)
- `tenant-accounts-as-tenant-root` — One tenant-root table both personal and team workspaces reference.
- `tenant-account-id-on-product-tables` — Tenant key + index + scoping policy on every product table.
- `tenant-slug-in-team-urls` — Use a human-readable slug in team URLs, not the UUID.
- `tenant-storage-paths-include-account-id` — Namespace object-storage paths by tenant id.
- `tenant-never-edit-generated-types` — Treat generated DB types as build output; regenerate, never hand-edit.
3. Request Boundary: Proxy (HIGH)
- `proxy-single-pipeline` — Compose the whole request pipeline in one
proxy.ts. - `proxy-redirect-auth-at-boundary` — Perform auth redirects at the proxy, not in pages.
- `proxy-url-pattern-matching` — Match proxy routes with
URLPattern, not string comparisons. - `proxy-set-correlation-id` — Set a correlation ID at the request boundary.
- `proxy-secure-headers-flagged` — Apply strict CSP headers behind an environment flag.
4. Server-Side Data Loading (HIGH)
- `server-cache-workspace-loaders` — Wrap per-request loaders with
cache()from React. - `server-promise-all-parallel-loads` — Load independent data in parallel with
Promise.all. - `server-use-feature-api-factories` — Read through a typed data-access factory, not raw
from('table'). - `server-redirect-on-missing-workspace` — Redirect from the loader when workspace state is invalid.
- `server-fetch-in-server-components` — Fetch initial data in server components, not on the client.
- `server-use-tables-generic-for-types` — Use generated row types, not hand-written interfaces.
- `server-services-receive-client` — Services receive the data client as a constructor argument.
5. Mutations: Actions & Route Handlers (HIGH)
- `mutate-use-safe-action-clients` — Route mutations through a typed action client you build on next-safe-action.
- `mutate-zod-schema-separate-file` — Put Zod schemas in their own
*.schema.tsshared by client and server. - `mutate-thin-action-service-holds-logic` — Keep the action thin; put business logic in a service.
- `mutate-use-getlogger-not-console` — Log through a structured logger you own, not
console.log. - `mutate-revalidate-path-after-write` — Call
revalidatePath()after a successful write. - `mutate-enhance-route-handler` — Wrap route handlers in a typed handler that owns auth and validation.
- `mutate-webhook-verify-signature` — Webhook routes skip user-auth and verify the provider signature.
6. Client/Server Boundaries (MEDIUM-HIGH)
- `client-use-client-at-leaves` — Mark
'use client'at leaf components, not page roots. - `client-pass-server-data-as-props` — Pass server data to client components as props, don't refetch.
- `client-use-supabase-with-react-query` — Pair a memoized browser client with TanStack Query for client reads.
- `client-realtime-cleanup-subscription` — Tear down any subscription or event source in the
useEffectreturn. - `client-use-action-hook` — Call server actions with
useActionfromnext-safe-action/hooks. - `client-stable-query-keys` — Use stable, hierarchical query keys.
7. Architecture & Services (MEDIUM)
- `arch-app-vs-packages-boundary` — Reusable capabilities in
packages/, product-specific code inapps/web. - `arch-data-access-adapter` — Confine the backend to one data-access package with a stable surface.
- `arch-feature-package-layout` — Feature packages follow a
components / hooks / schema / serverlayout. - `arch-import-via-package-exports` — Import via the package
exportsmap, never deep internal paths. - `arch-provider-gateway-pattern` — Hide vendor SDKs behind a gateway interface.
- `arch-policy-engine-for-business-rules` — Model business rules in a policy layer you own, not inline conditionals.
- `arch-config-driven-navigation` — Define routes and navigation in
config/, not hardcoded in components.
8. Forms & UI Conventions (MEDIUM)
- `ui-rhf-zod-no-generics` — Let
zodResolverinfer form types; don't adduseFormgenerics. - `ui-form-message-per-field` — Include
FormMessagefor every field. - `ui-kit-ui-package-imports` — Import UI from your
@app/uidesign-system surface, never internal paths. - `ui-semantic-tailwind-tokens` — Use semantic Tailwind tokens, not hardcoded colors.
- `ui-base-ui-render-not-aschild` — Use Base UI
renderprop, not RadixasChild. - `ui-trans-for-display-text` — Render display text through
<Trans>oruseTranslations.
How to Use
Read individual reference files for detailed explanations and code examples:
- Section definitions — Category structure, impact levels, lifecycle rationale.
- Rule template — Template for adding new rules.
- AGENTS.md — Auto-generated TOC for fast navigation.
Reference Files
| File | Description |
|---|---|
| references/_sections.md | Category definitions and ordering by lifecycle impact |
| assets/templates/_template.md | Template for adding new rules |
| metadata.json | Version, organization, references |
Related Skills
- `base-ui-migrator` — bulk-migrate Radix
asChildpatterns to Base UIrenderprops. - `tailwind-refactor` — refactor hardcoded colors to semantic tokens.
- `react-optimise` — performance optimisations for React components.
- `nextjs-bundle-optimizer` — bundle analysis and reduction for Next.js apps.
Next.js 16 (App Router)
Version 0.2.0 Community May 2026
---
Abstract
Opinionated, backend-agnostic implementation-pattern reference for Next.js 16 (App Router) codebases. Contains 50 rules across 8 categories, prioritized by execution-lifecycle cascade impact — from authorization at the data layer (CRITICAL) and optional multi-tenant modeling (CRITICAL) through the proxy.ts request boundary, server-side loaders, mutations, client/server boundaries, architecture, and UI conventions. Each rule states a transferable principle and includes a why-it-matters explanation plus Incorrect/Correct code examples. Examples use Supabase as the concrete backend, with a Transferable note where the store matters (Drizzle, Prisma). The target structure is a Turbo monorepo of @app/* packages built on canonical libraries — next-safe-action, @supabase/ssr, @tanstack/react-query, react-hook-form + zod, shadcn/ui, next-intl, pino — that confine the backend behind one data-access package, rather than a vendored starter kit.
---
Table of Contents
1. Authorization & Data-Layer Access — CRITICAL
- 1.1 Authorize Once at the Data Layer — Don't Re-Check the Same Rule in App Code — CRITICAL (prevents drift between application-layer and database-layer authz)
- 1.2 Centralize the Auth Gate in One `requireUser()` Helper Instead of Scattering Raw Claim Checks — CRITICAL (prevents skipping the MFA verification gate)
- 1.3 Enforce MFA at the Proxy Boundary, Not Per-Page — CRITICAL (prevents MFA bypass on protected routes)
- 1.4 Gate the Privileged Client Behind an Authorization Check Done Before You Construct It — CRITICAL (prevents privilege escalation when the data layer is bypassed)
- 1.5 Mark Server-Only Modules with `import 'server-only'` — CRITICAL (prevents server secrets from bundling into the client)
- 1.6 Read Through the Request-Scoped Auth-Bound Client, Never the Privileged Client by Default — CRITICAL (prevents cross-tenant data leaks)
- 1.7 Use Reusable SQL Helper Functions Inside RLS Policies — CRITICAL (prevents inconsistent authorization logic across tables)
2. Multi-Tenancy — *CRITICAL (when building multi-tenant SaaS; skip this category entirely for single-tenant apps)***
- 2.1 Anchor Every Tenant on One Root Table That Personal and Team Workspaces Both Reference — CRITICAL (prevents fragmented authorization models)
- 2.2 Give Every Tenant-Scoped Table the Tenant Key, an Index on It, and a Policy That Filters by It — CRITICAL (prevents tenant isolation gaps and slow queries)
- 2.3 Namespace Object-Storage Paths by Tenant ID So Access Rules Can Match on the Path — HIGH (enables per-tenant storage RLS + cleanup on account delete)
- 2.4 Put a Human-Readable Tenant Slug in Team URLs, Not the Tenant UUID — HIGH (prevents UUID leakage in URLs and decouples routing from primary keys)
- 2.5 Treat Generated DB Types as Build Output — Regenerate, Never Hand-Edit — HIGH (prevents silent type/schema drift)
3. Request Boundary: Proxy — HIGH
- 3.1 Apply Strict CSP Headers Behind an Environment Flag — MEDIUM (prevents broken dev workflows while enforcing XSS protection in prod)
- 3.2 Compose the Whole Request Pipeline in One `proxy.ts` — HIGH (prevents per-route request-handling drift)
- 3.3 Match Proxy Routes with `URLPattern`, Not String Comparisons — MEDIUM-HIGH (prevents over-matching and trailing-slash misses)
- 3.4 Perform Auth Redirects at the Proxy, Not in Pages — HIGH (prevents redirect duplication and double round-trips)
- 3.5 Set a Correlation ID at the Request Boundary — MEDIUM-HIGH (enables end-to-end request tracing through services)
4. Server-Side Data Loading — HIGH
- 4.1 Fetch Initial Data in Server Components, Not on the Client — HIGH (100-500ms saved per page load (removes a hydration round-trip))
- 4.2 Load Independent Data in Parallel with `Promise.all` — HIGH (2-3x faster loaders with N concurrent reads)
- 4.3 Read Through a Typed Data-Access Factory, Not Raw `from('table')` — HIGH (prevents table-knowledge scattering across UI and loaders)
- 4.4 Redirect from the Loader When Workspace State Is Invalid — MEDIUM-HIGH (prevents rendering layouts with null data)
- 4.5 Services Receive the Data Client as a Constructor Argument — MEDIUM-HIGH (enables client-choice injection and unit-test mocking)
- 4.6 Use Generated Row Types (`Tables<'name'>`), Not Hand-Written Interfaces — MEDIUM (prevents schema drift in application types)
- 4.7 Wrap Per-Request Loaders with `cache()` from React — HIGH (prevents N duplicate queries across nested layouts)
5. Mutations: Server Actions & Route Handlers — HIGH
- 5.1 Call `revalidatePath()` After a Successful Write So the UI Refreshes — HIGH (prevents stale UI after writes)
- 5.2 Keep the Action Thin — Put Business Logic in a Service — MEDIUM-HIGH (enables reuse across actions, route handlers, jobs, and tests)
- 5.3 Log Through a Structured Logger You Own, Not `console.log` — MEDIUM (enables structured logs with correlation IDs and severity routing)
- 5.4 Put Zod Schemas in Their Own `*.schema.ts` File Shared by Client and Server — HIGH (enables schema reuse between server action and client form)
- 5.5 Run Every Mutation Through a Typed Auth-Checked Action Client You Build on next-safe-action — HIGH (prevents per-mutation auth/validation drift)
- 5.6 Webhook Routes Skip the User-Auth Wrapper and Verify the Provider Signature — HIGH (prevents unauthenticated invocation of admin-privileged handlers)
- 5.7 Wrap Route Handlers in a Typed Handler That Owns Auth and Validation — HIGH (prevents per-route auth/validation drift across handlers)
6. Client/Server Boundaries — MEDIUM-HIGH
- 6.1 Call Server Actions with `useAction` from `next-safe-action/hooks` — MEDIUM-HIGH (prevents per-form reimplementation of loading/error/typing)
- 6.2 Mark `'use client'` at Leaf Components, Not Page Roots — HIGH (saves 50-200KB per misplaced boundary)
- 6.3 Pair a Memoized Browser Data Client with TanStack Query for Client-Side Reads — MEDIUM-HIGH (prevents duplicate fetches across hooks)
- 6.4 Pass Server Data to Client Components as Props, Don't Refetch — HIGH (prevents double round-trip for already-loaded data)
- 6.5 Tear Down Any Subscription or Event Source in the `useEffect` Return — MEDIUM-HIGH (prevents memory leaks and duplicate event handlers per dep change)
- 6.6 Use Stable, Hierarchical Query Keys — MEDIUM (prevents cache collisions and unnecessary refetches)
7. Architecture & Services — MEDIUM
- 7.1 Confine the Backend to One Data-Access Package with a Stable Surface — MEDIUM (keeps the data store swappable and testable)
- 7.2 Define Routes and Navigation in `config/`, Not Hardcoded in Components — MEDIUM (prevents route-name drift across files)
- 7.3 Feature Packages Follow a `components / hooks / schema / server` Layout — MEDIUM (prevents structural drift across feature packages)
- 7.4 Hide Vendor SDKs Behind a Gateway Interface — MEDIUM (enables swappable billing, mail, CMS, monitoring providers)
- 7.5 Import via the Package `exports` Map, Never Deep Internal Paths — MEDIUM (prevents coupling consumers to internal file structure)
- 7.6 Model Business Rules in a Policy Layer You Own — Not Inline Conditionals — MEDIUM-HIGH (prevents business-rule scatter across actions, forms, and services)
- 7.7 Place Reusable Capabilities in `packages/`, Product-Specific Code in `apps/web` — MEDIUM (prevents tight coupling between product composition and reusable platform)
8. Forms & UI Conventions — MEDIUM
- 8.1 Import UI from Your Design-System Package Surface, Never Internal Paths — MEDIUM (prevents bypassing your design-system wrapper behavior)
- 8.2 Include `FormMessage` for Every Field — MEDIUM (prevents silent validation failures and unreadable error states)
- 8.3 Let `zodResolver` Infer Form Types — Don't Add `useForm` Generics — MEDIUM (prevents type/schema drift in forms)
- 8.4 Render Display Text Through `<Trans>` or `useTranslations` — MEDIUM (prevents untranslated strings shipping to non-English locales)
- 8.5 Use Base UI `render` Prop, Not Radix `asChild` — MEDIUM (prevents silent prop drops on misused composition)
- 8.6 Use Semantic Tailwind Tokens, Not Hardcoded Colors — MEDIUM (prevents dark-mode and theme drift across components)
---
References
1. https://nextjs.org/docs/app 2. https://nextjs.org/docs/app/api-reference/file-conventions/proxy 3. https://next-safe-action.dev/ 4. https://tanstack.com/query/latest/docs/framework/react/overview 5. https://react.dev/reference/react/cache 6. https://react.dev/reference/rsc/use-client 7. https://react-hook-form.com/ 8. https://zod.dev/ 9. https://ui.shadcn.com/docs 10. https://base-ui.com/react/handbook/composition 11. https://next-intl.dev/ 12. https://turborepo.com/docs/core-concepts/internal-packages 13. https://supabase.com/docs/guides/auth/server-side/nextjs 14. https://supabase.com/docs/guides/database/postgres/row-level-security
---
Source Files
This document was compiled from individual reference files. For detailed editing or extension:
| File | Description |
|---|---|
| references/_sections.md | Category definitions and ordering |
| assets/templates/_template.md | Template for creating new rules |
| SKILL.md | Quick reference entry point |
| metadata.json | Version and reference URLs |
{Same as title above}
{1-3 sentences explaining WHY this matters in concrete terms. Describe what goes wrong without this pattern — the cascade effect, the failure mode, the specific bug class it prevents. The model generalises from understood reasoning, so this section is the highest-signal part of the rule. Avoid "Always do X" or "Never do Y" without saying why; the reasoning is what lets the model apply the pattern correctly in novel situations.}
Incorrect ({short label for what's wrong}):
```{language: ts|tsx|sql|json|bash|text} // Production-realistic code — not strawman. // Annotate with parenthetical comments explaining the cost. const result = badPattern(); // Cost: what goes wrong, in concrete terms.
**Correct ({short label for what's right}):**
// Minimal diff from the Incorrect example — same structure, fixed pattern. const result = goodPattern(); // Benefit: what this gains, in concrete terms.
{Optional sections — include only when they add clarity:}
**Alternative ({short context}):**
// When multiple valid approaches exist, show the second one. const result = alternativePattern();
**When NOT to use this pattern:**
- {Edge case 1 with a specific signal — "if you have a single-row table" or "if the data is already paginated server-side"}
- {Edge case 2}
**Why this isn't ({common counterargument}):** {1-2 sentences addressing the most likely pushback. Often the reader's instinct is "this seems excessive" — pre-empt it.}
**Where this matters most:** {Concrete situations where this rule pays off — the hottest path, the most common bug, the highest-stakes feature.}
Reference: [{Source title}]({URL})
---
### Authoring Guidelines
**Required frontmatter fields:**
| Field | Format | Example |
|-------|--------|---------|
| `title` | Imperative phrase | "Use `authActionClient` for Authenticated Mutations" |
| `impact` | One of: CRITICAL, HIGH, MEDIUM-HIGH, MEDIUM, LOW-MEDIUM, LOW | `HIGH` |
| `impactDescription` | Quantified: "Nx", "Nms", "O(x) to O(y)", "%", "prevents X", "saves Y", "reduces Z" | `prevents cross-tenant data leaks` |
| `tags` | Comma-separated. First tag MUST be the category prefix | `auth, supabase, rls, server-client` |
**Quote the title in frontmatter** when it contains a colon (`:`), backticks containing colons, or any special YAML character:
title: "Use auth: false for Webhook Routes"
**Title patterns:**
| Pattern | When | Example |
|---------|------|---------|
| `Use X for Y` | Recommending a tool / pattern | "Use `revalidatePath()` After Mutations" |
| `Avoid X` | Prohibiting | "Avoid Deep Imports from `@app/ui/src/*`" |
| `{Verb} {Object} in {Context}` | Contextual action | "Cache Workspace Loaders in `cache()`" |
| `{X} for {Y}` | Tool + use case | "`Promise.all` for Independent Reads" |
**Impact description patterns:**
| Type | Pattern | Example |
|------|---------|---------|
| Multiplier | `N-Mx improvement` | `2-10x faster` |
| Time | `N-Mms savings` | `200-500ms saved per page load` |
| Complexity | `O(x) to O(y)` | `O(n) to O(1)` |
| Prevention | `prevents {problem}` | `prevents cross-tenant data leaks` |
| Reduction | `reduces {thing} by N%` | `reduces bundle by 30%` |
| Saving | `saves {thing}` | `saves a hydration round-trip` |
**Language patterns:**
| Do | Don't |
|----|-------|
| Imperative: "Use", "Wrap", "Mark", "Embed" | Hedging: "consider", "you might want to" |
| Specific quantities: "200ms savings" | Vague: "faster", "significant improvement" |
| Concrete reasoning: "because X causes Y" | Dictation: "MUST do X" without reasoning |
| Production-realistic code | Strawman: `function foo() { return bar; }` |
**Filename convention:** `{prefix}-{kebab-case-title}.md` — e.g., `auth-use-standard-server-client.md`. The prefix MUST match the first tag and MUST be defined in `_sections.md`.
**Reference URLs:** prefer (1) official Next.js docs, (2) the prescribed library's official docs (next-safe-action, TanStack Query, React Hook Form, Zod, shadcn/ui, Base UI, next-intl, pino, Turborepo), (3) official backend docs (Supabase, Drizzle, Prisma) for the example, (4) MDN for web platform APIs. Avoid tutorial sites, Stack Overflow, personal blogs without benchmark data.
{
"version": "0.2.0",
"organization": "Community",
"technology": "Next.js 16 (App Router)",
"discipline": "distillation",
"type": "code-quality",
"date": "May 2026",
"abstract": "Opinionated, backend-agnostic implementation-pattern reference for Next.js 16 (App Router) codebases. Contains 50 rules across 8 categories, prioritized by execution-lifecycle cascade impact — from authorization at the data layer (CRITICAL) and optional multi-tenant modeling (CRITICAL) through the proxy.ts request boundary, server-side loaders, mutations, client/server boundaries, architecture, and UI conventions. Each rule states a transferable principle and includes a why-it-matters explanation plus Incorrect/Correct code examples. Examples use Supabase as the concrete backend, with a Transferable note where the store matters (Drizzle, Prisma). The target structure is a Turbo monorepo of @app/* packages built on canonical libraries — next-safe-action, @supabase/ssr, @tanstack/react-query, react-hook-form + zod, shadcn/ui, next-intl, pino — that confine the backend behind one data-access package, rather than a vendored starter kit.",
"references": [
"https://nextjs.org/docs/app",
"https://nextjs.org/docs/app/api-reference/file-conventions/proxy",
"https://next-safe-action.dev/",
"https://tanstack.com/query/latest/docs/framework/react/overview",
"https://react.dev/reference/react/cache",
"https://react.dev/reference/rsc/use-client",
"https://react-hook-form.com/",
"https://zod.dev/",
"https://ui.shadcn.com/docs",
"https://base-ui.com/react/handbook/composition",
"https://next-intl.dev/",
"https://turborepo.com/docs/core-concepts/internal-packages",
"https://supabase.com/docs/guides/auth/server-side/nextjs",
"https://supabase.com/docs/guides/database/postgres/row-level-security"
]
}
Sections
This file defines all sections, their ordering, impact levels, and descriptions. The section ID (in parentheses) is the filename prefix used to group rules.
Categories are ordered by execution-lifecycle cascade: mistakes earlier in the request flow (authorization, tenant modeling, request boundary, server loads, mutations) block or corrupt every downstream stage; mistakes in client and presentation layers only affect that subtree.
The rules are backend-agnostic in principle but use Supabase as the concrete example backend. Where the backend genuinely matters, a rule carries a Transferable: note explaining the pattern for other stores (Drizzle, Prisma).
---
1. Authorization & Data-Layer Access (auth)
Impact: CRITICAL Description: Reaching for the privileged/service client by default, or re-deriving authorization in TypeScript instead of enforcing it once at the data layer, punches a hole through access control for every read and write that follows. Push authorization into the data layer (Postgres RLS here) and trust it.
2. Multi-Tenancy (tenant) — optional, SaaS only
Impact: CRITICAL Description: Optional — applies only when building multi-tenant SaaS; skip this category entirely for single-tenant apps. For multi-tenant apps: a single tenant-root table, the tenant key + index + scoping policy on every product table, slug-based URLs, and tenant-namespaced storage paths are the shape every access rule depends on — getting it wrong is unfixable later without a backfill migration. A marketing site, blog, or internal tool can ignore this whole category.
3. Request Boundary: Proxy (proxy)
Impact: HIGH Description: proxy.ts (Next.js 16's renamed middleware — exported proxy, Node.js runtime) is the single entry point for every request — handling i18n routing, auth redirects, MFA enforcement, secure headers, and request IDs in one pipeline so pages never repeat these checks.
4. Server-Side Data Loading (server)
Impact: HIGH Description: cache()-wrapped loaders, Promise.all parallel reads, typed data-access factories, and generated row types keep server components fast and consistent — fetching on the client instead, or skipping cache(), creates waterfalls and duplicate queries on every layout render.
5. Mutations: Server Actions & Route Handlers (mutate)
Impact: HIGH Description: A typed action client (built on next-safe-action) and a typed route-handler wrapper are the only two sanctioned write paths — they enforce Zod validation, authentication, structured logging, and revalidatePath so every mutation is safe, typed, and refreshes the right UI. Business logic lives in a service the action stays thin around.
6. Client/Server Boundaries (client)
Impact: MEDIUM-HIGH Description: 'use client' belongs at leaf components, server data flows down as props, and client data uses a memoized browser client + TanStack Query with stable keys — moving the boundary up the tree balloons the bundle, and refetching server-loaded data on the client wastes a render cycle.
7. Architecture & Services (arch)
Impact: MEDIUM Description: Product composition lives in apps/web, reusable capabilities live in packages/* (@app/*), the backend is confined to one data-access package, business logic lives in services (not actions), authorization rules live in a policy layer, and providers (billing/mail/CMS) hide behind gateway interfaces — collapsing these boundaries makes the codebase resistant to future change and locks in the vendor.
8. Forms & UI Conventions (ui)
Impact: MEDIUM Description: React Hook Form + Zod with a separate schema file, imports through your @app/ui design-system surface (shadcn/ui wrappers), semantic Tailwind tokens, Base UI render (not Radix asChild), Trans/useTranslations for display text, and data-test on interactive elements keep the UI consistent, translatable, and testable.
Place Reusable Capabilities in packages/, Product-Specific Code in apps/web
apps/web is the product composition layer — it owns routes, configs, layouts, feature flags, and the specific way this product wires capabilities together. Your workspace packages under packages/* (published as @app/*) are reusable capabilities — they don't know which product uses them and shouldn't reference product-specific paths, configs, or flags. Misplacing code breaks the boundary: feature-specific code in a package means a future second app can't avoid the feature; reusable code in apps/web means it can't be lifted into another app without an extraction migration.
Incorrect (product-specific config reached into a package):
// packages/features/projects/src/server/projects-api.ts
import featureFlagsConfig from 'apps/web/config/feature-flags.config'; // ❌ app config
import pathsConfig from 'apps/web/config/paths.config'; // ❌ app routes
export class ProjectsApi {
// The package now depends on apps/web's config files.
// Add apps/admin or apps/marketing later and this package can't be reused there.
}Correct (package is self-contained; the app composes it):
// packages/features/projects/src/server/projects-api.ts
export class ProjectsApi {
constructor(private readonly client: DataClient) {}
async listProjects(accountId: string, options?: { limit?: number }) {
/* reads scoped to accountId — no knowledge of which app calls it */
}
}
export function createProjectsApi(client: DataClient) {
return new ProjectsApi(client);
}// apps/web/app/[locale]/home/[account]/projects/page.tsx
import featureFlagsConfig from '~/config/feature-flags.config';
import { createProjectsApi } from '@app/projects/api';
import { getServerClient } from '@app/supabase/server';
// apps/web KNOWS the feature flag and decides whether to compose the feature.
// The package stays oblivious.
export default async function ProjectsPage({ params }: { params: { account: string } }) {
if (!featureFlagsConfig.enableProjects) {
return <UpgradePrompt />;
}
const projectsApi = createProjectsApi(getServerClient());
const projects = await projectsApi.listProjects(params.account);
return <ProjectsList projects={projects} />;
}Decision matrix:
| Question | Belongs in apps/web | Belongs in packages/* (@app/*) |
|---|---|---|
| Does it import a feature flag? | ✅ | ❌ |
| Does it know a specific route name? | ✅ | ❌ |
| Does it know the app name / branding? | ✅ | ❌ |
| Is it a route, layout, or page component? | ✅ | ❌ |
| Could a second app use it unchanged? | ❌ | ✅ |
| Is it a feature API, hook, schema, or service? | ❌ | ✅ |
| Is it a UI primitive? | ❌ | ✅ (in packages/ui, @app/ui) |
Borderline: route-local code that uses a feature package. Route-local _lib/server/*.ts files live under apps/web/app/... — they belong to the route, even when they call feature-package APIs. The convention: composition glue is route-local, the API itself lives in the package.
apps/web/app/[locale]/home/[account]/projects/
├── page.tsx # apps/web
├── _components/
│ └── project-list.tsx # apps/web (uses @app/projects)
├── _lib/server/
│ └── projects-page.loader.ts # apps/web (composes the feature)
└── _lib/projects-page.schema.ts # apps/web (route-specific schema)The reusable bits (projects-api.ts, hooks/, generic components) live in packages/features/projects/.
Don't refactor by moving things between app and packages on a whim. The boundary is meaningful — every move is a "is this reusable enough?" decision. If the same component appears in two route directories, that's a signal to extract; one-off composition glue belongs where it's used.
Cross-app shared code: when a future app is added (apps/admin, apps/marketing), shared apps-level code goes in packages/shared (@app/shared) or a new packages/{name} — never one app importing from another.
Reference: Turborepo internal packages
Define Routes and Navigation in config/, Not Hardcoded in Components
apps/web/config/paths.config.ts is the canonical source for every route name in the app — pathsConfig.app.home === '/home', pathsConfig.auth.signIn === '/auth/sign-in'. Navigation configs (personal-account-navigation.config.tsx, team-account-navigation.config.tsx) define the sidebar entries that read these constants. Sidebars import navigation config; redirects and links import path constants; renaming a route is a one-file diff instead of a grep-and-replace across 20 components.
Incorrect (hardcoded paths sprinkled through components):
// 20 different files each contain:
<Link href="/home/settings">Settings</Link>
redirect('/auth/sign-in');
router.push('/home/' + account + '/billing');
// Rename /home/settings to /home/account-settings: hunt through every file.
// Add a /home prefix change (e.g., /workspace): another grep-and-replace.
// Typo in one of the 20 places: silently dead link.Correct (single source of truth in `paths.config.ts`):
// apps/web/config/paths.config.ts (Zod-validated for shape correctness)
import * as z from 'zod';
const PathsSchema = z.object({
auth: z.object({
signIn: z.string().min(1),
signUp: z.string().min(1),
verifyMfa: z.string().min(1),
callback: z.string().min(1),
}),
app: z.object({
home: z.string().min(1),
personalAccountSettings: z.string().min(1),
accountHome: z.string().min(1), // Template: '/home/[account]'
accountSettings: z.string().min(1),
}),
});
const pathsConfig = PathsSchema.parse({
auth: {
signIn: '/auth/sign-in',
signUp: '/auth/sign-up',
verifyMfa: '/auth/verify',
callback: '/auth/callback',
},
app: {
home: '/home',
personalAccountSettings: '/home/settings',
accountHome: '/home/[account]',
accountSettings: '/home/[account]/settings',
},
} satisfies z.output<typeof PathsSchema>);
export default pathsConfig;// Every consumer uses the constant.
import pathsConfig from '~/config/paths.config';
<Link href={pathsConfig.app.personalAccountSettings}>Settings</Link>
redirect(pathsConfig.auth.signIn);
router.push(pathsConfig.app.accountHome.replace('[account]', account));Navigation configs are TSX so they can include icons:
// apps/web/config/team-account-navigation.config.tsx
import { Home, Settings, Users, CreditCard } from 'lucide-react';
import pathsConfig from './paths.config';
export const getTeamAccountNavigationConfig = (account: string) => ({
routes: [
{
label: 'common:routes.home',
path: pathsConfig.app.accountHome.replace('[account]', account),
Icon: <Home className="h-4" />,
},
{
label: 'common:routes.members',
path: `/home/${account}/members`,
Icon: <Users className="h-4" />,
},
{
label: 'common:routes.billing',
path: `/home/${account}/billing`,
Icon: <CreditCard className="h-4" />,
},
{
label: 'common:routes.settings',
path: pathsConfig.app.accountSettings.replace('[account]', account),
Icon: <Settings className="h-4" />,
},
],
});// Sidebar consumes the navigation config — no hardcoded links.
import { getTeamAccountNavigationConfig } from '~/config/team-account-navigation.config';
export function TeamSidebar({ account }: { account: string }) {
const config = getTeamAccountNavigationConfig(account);
return (
<nav>
{config.routes.map((route) => (
<NavLink key={route.path} href={route.path} icon={route.Icon}>
<Trans i18nKey={route.label} />
</NavLink>
))}
</nav>
);
}Feature flags also live in `config/`:
// apps/web/config/feature-flags.config.ts
export default {
enableTeamAccounts: true,
enablePersonalAccountDeletion: process.env.NEXT_PUBLIC_ENABLE_PERSONAL_ACCOUNT_DELETION === 'true',
enableProjects: true,
};
// Consumers read this single source — never re-check the env var in components.
import featureFlagsConfig from '~/config/feature-flags.config';
if (!featureFlagsConfig.enableTeamAccounts) return null;Zod-validating the config catches shape errors at startup. A typo like signIn: '/atuh/sign-in' is caught when paths.config.ts first loads — not at the first time a user clicks the link.
When you add a route: update paths.config.ts AND the appropriate navigation config. The PR diff for a new route is two config files plus the route's actual code — and reviewers see the whole change in one place.
Don't bypass for "one-off" links. Even a single hardcoded '/home/settings' is a future grep miss. Always use the constant.
Reference: Next.js linking and navigating
Confine the Backend to One Data-Access Package with a Stable Surface
If feature code imports @supabase/ssr and calls .from('table') everywhere, the store leaks into every layer: an SDK upgrade or a move to Drizzle/Prisma means touching hundreds of call sites, and nothing is unit-testable without a live database. Put the store behind a single workspace package (@app/supabase here) that owns client construction and typed access; every feature depends on that surface, never on the vendor SDK directly. Swapping backends becomes one package reimplementation instead of a codebase-wide edit.
Incorrect (the vendor SDK leaks into a feature package):
// features/projects/server/projects.repository.ts
import { createServerClient } from '@supabase/ssr'; // vendor SDK imported in feature code
import { cookies } from 'next/headers';
export async function listProjects(accountId: string) {
const client = createServerClient(/* url, key, cookie wiring repeated here */);
return client.from('projects').select('*').eq('account_id', accountId);
}Correct (the feature depends only on the data-access surface):
// features/projects/server/projects.repository.ts
import { getServerClient } from '@app/supabase/server'; // the only data-access entry point
import type { Tables } from '@app/supabase/types';
export async function listProjects(accountId: string): Promise<Tables<'projects'>[]> {
const client = getServerClient();
const { data } = await client.from('projects').select('*').eq('account_id', accountId);
return data ?? [];
}The data-access package exposes one small surface (packages/supabase/package.json):
{
"name": "@app/supabase",
"exports": {
"./server": "./src/server.ts", // request-scoped, auth-bound client
"./admin": "./src/admin.ts", // privileged service-role client (guarded callers only)
"./client": "./src/client.ts", // memoized browser client
"./types": "./src/types.ts" // generated row types
}
}Transferable: to move off Supabase, reimplement @app/supabase (or publish a parallel @app/db built on Drizzle/Prisma) exposing the same server / admin / client / types entry points. Because feature packages import only that surface — never the vendor SDK — their code does not change. This is ports-and-adapters: the package is the port, the vendor SDK is one adapter.
Reference: Turborepo internal packages
Feature Packages Follow a components / hooks / schema / server Layout
Give every feature package under packages/features/ the same shape: components/ for shared client UI, hooks/ for client-side hooks, schema/ for Zod definitions reusable on both sides, server/api.ts for the feature API factory, server/actions/ for server actions, server/services/ for business logic, and server/policies/ when the feature has policy gating. New contributors find what they need without grepping; refactoring patterns work across features; one ESLint rule can enforce the layout instead of one per package.
Incorrect (each feature invents its own structure):
packages/features/projects/
├── src/
│ ├── api.ts # Server-only, but server/ is named differently
│ ├── ProjectCard.tsx # PascalCase, no folder grouping
│ ├── helpers.ts # Mixed client/server, unclear purpose
│ ├── projectActions.ts # Server actions mixed with regular code
│ └── form-validation.ts # Zod schema, but tooling won't find itCorrect (the conventional layout your packages all share):
packages/features/<feature-name>/
├── package.json # Exports map: ./api, ./components, ./hooks, etc.
└── src/
├── components/ # Reusable client UI for this feature
│ ├── project-card.tsx
│ └── create-project-form.tsx
├── hooks/ # Client-side hooks (data client + React Query)
│ ├── use-projects.ts
│ └── use-create-project.ts
├── schema/ # Zod schemas (shared client + server)
│ ├── create-project.schema.ts
│ └── update-project.schema.ts
└── server/ # Server-only code (mark with import 'server-only')
├── api.ts # Feature API factory: createProjectsApi(client)
├── actions/ # Server actions
│ ├── create-project-server-actions.ts
│ └── delete-project-server-actions.ts
├── services/ # Business logic
│ ├── create-project.service.ts
│ └── delete-project.service.ts
└── policies/ # Optional: when feature has configurable rules
├── invitation-policies.ts
└── invitation-policy-context-builder.ts`package.json` exports:
{
"name": "@app/projects",
"exports": {
"./api": "./src/server/api.ts",
"./components/project-card": "./src/components/project-card.tsx",
"./hooks/use-projects": "./src/hooks/use-projects.ts",
"./schema": "./src/schema/index.ts",
"./server/actions": "./src/server/actions/index.ts"
}
}Consumers import @app/projects/api or @app/projects/hooks/use-projects — not @app/projects/src/server/api. (See the "import via package exports" rule.)
File naming inside the layout:
| Folder | Naming | Example |
|---|---|---|
components/ | kebab-case | project-card.tsx |
hooks/ | use-* kebab-case | use-projects.ts |
schema/ | {action}.schema.ts | create-project.schema.ts |
server/actions/ | {feature}-server-actions.ts or {action}.action.ts | create-project-server-actions.ts |
server/services/ | {action}.service.ts | create-project.service.ts |
server/policies/ | {feature}-policies.ts, *-context-builder.ts | invitation-policies.ts |
server/api.ts | Always exactly this filename | api.ts |
Why a fixed `server/` folder: the directory itself is a hint to add import 'server-only' to every file inside. Tooling can enforce "if path ends in /server/*.ts, file must start with import 'server-only'" with a one-line lint rule.
When to deviate: very small features (one component, one hook) can skip empty folders. A package with only src/server/api.ts is fine. Don't create empty folders for the sake of conformance — but if you add the second file in a category, create the folder.
Don't put `_internal/` or implementation-detail folders in the public surface. If something shouldn't be imported, omit it from the exports map. Consumers can't import what isn't exported.
Reference: Turborepo internal packages
Import via the Package exports Map, Never Deep Internal Paths
Each package's package.json declares an exports map that names the public surface (@app/ui/button → ./src/shadcn/button.tsx, @app/ui/form → ./src/components/form.tsx). Importing through this contract means the package can reshape internally — move files, rename folders, wrap an upstream primitive with project-specific behavior — without breaking consumers. Deep imports bypass the contract and break the next time the package's internal layout changes.
Incorrect (deep import — bypasses the contract):
import { Button } from '@app/ui/src/shadcn/button'; // ❌ Internal path.
import { FormMessage } from '@app/ui/src/components/form'; // ❌ Internal path.
import { useSupabase } from '@app/supabase/src/hooks/use-supabase'; // ❌
// First time @app/ui refactors src/shadcn → src/primitives, every consumer breaks.
// First time @app/ui wraps Button with project-specific render logic,
// these deep imports skip the wrapper and miss the new behavior.Correct (import via the declared exports):
import { Button } from '@app/ui/button';
import { FormMessage } from '@app/ui/form';
import { useSupabase } from '@app/supabase/client';
import { Trans } from '@app/ui/trans';
import { Form, FormField, FormItem, FormLabel, FormControl } from '@app/ui/form';What the exports map looks like (`packages/ui/package.json`):
{
"name": "@app/ui",
"exports": {
"./accordion": "./src/shadcn/accordion.tsx",
"./alert-dialog": "./src/shadcn/alert-dialog.tsx",
"./button": "./src/shadcn/button.tsx",
"./form": "./src/components/form.tsx",
"./trans": "./src/components/trans.tsx",
"./sonner": "./src/components/sonner.tsx",
"./hooks/use-mobile": "./src/hooks/use-mobile.ts",
"./hooks/use-upload": "./src/hooks/use-upload.ts"
}
}Why `@app/ui/form` points to `components/form.tsx`: your package wraps the upstream shadcn Form with project-specific behavior (an i18n-aware FormMessage). Deep-importing from @app/ui/src/shadcn/form would get the unwrapped version and miss the i18n integration.
Path aliases in `apps/web`:
| Alias | Resolves to | Use for |
|---|---|---|
~/config/* | apps/web/config/* | App config files (paths, feature flags) |
~/components/* | apps/web/components/* | App-shared components (not route-local) |
~/lib/* | apps/web/lib/* | App utilities |
~/* | Auto-resolves into apps/web/app/* | Inside apps/web only |
// apps/web/app/[locale]/home/[account]/billing/page.tsx
import pathsConfig from '~/config/paths.config';
import { TopBar } from '~/components/top-bar';Outside apps/web (in packages/*), never use ~/* — packages don't have that alias and shouldn't know the host app's layout.
Discovering the exports surface: open the package's package.json and read the exports map. Editors with TypeScript path-completion only suggest declared exports — if your IDE doesn't auto-complete the import, the path is not public. Use the closest declared export instead.
Adding a new export: edit package.json's exports map AND make sure the file at the target path actually exists. Forgetting either step breaks the import in a confusing way (the editor accepts the path, runtime fails).
Internal imports within a package CAN use relative paths or `#`-aliases:
// packages/ui/src/components/form.tsx (internal — can use #-imports)
import { Form as ShadcnForm } from '#components/form'; // Internal alias.
import { cn } from '#utils';These # aliases are declared in package.json's imports field — they're private to the package and don't appear in the exports map.
Don't add a re-export shim in your own code "to avoid deep imports." That's just relocating the problem. Use the package's exports as published.
Reference: Node.js subpath exports
Model Business Rules in a Policy Layer You Own — Not Inline Conditionals
Build a small policy layer in @app/authz for business rules that need to be configurable, staged, composable, and surfaceable to the UI with actionable error messages. Instead of scattering if (!subscription.active) return { error: 'upgrade' } across forms, actions, and services, definePolicy once, register it in a feature-scoped registry, and evaluate via createPoliciesEvaluator() at the right stage (preliminary check before form submit; final check inside the action). The denied state carries a structured { code, message, remediation } so the UI knows what to say and what to suggest. This is the same idea as an ability/policy evaluator like CASL — a layer of declarative rules the rest of the app asks instead of re-deriving conditions everywhere.
Incorrect (inline conditionals scattered through the codebase):
// In the form component:
if (!subscription?.active) return <UpgradePrompt />;
// In the loader:
if (subscription?.provider === 'paddle' && subscription.status === 'trialing') {
const hasPerSeat = subscription.items.some((item) => item.type === 'per_seat');
if (hasPerSeat) redirect('/upgrade');
}
// In the server action:
'use server';
export const inviteMemberAction = authActionClient.action(async () => {
// Duplicate the same checks. Drift between form and action. Each location
// has its own error message. Adding a new constraint = editing 3+ places.
});Correct (declarative policies + registry + evaluator):
// packages/features/team-accounts/src/server/policies/invitation-policies.ts
import { allow, definePolicy, deny, createPolicyRegistry } from '@app/authz';
import { FeaturePolicyInvitationContext } from './feature-policy-invitation-context';
// 1. Define each policy as a pure function of context.
export const subscriptionRequiredInvitationsPolicy =
definePolicy<FeaturePolicyInvitationContext>({
id: 'subscription-required',
stages: ['preliminary', 'submission'], // Runs at both UI-load AND submit.
evaluate: async ({ subscription }) => {
if (!subscription?.active) {
return deny({
code: 'SUBSCRIPTION_REQUIRED',
message: 'teams.policyErrors.subscriptionRequired',
remediation: 'teams.policyRemediation.subscriptionRequired',
});
}
return allow();
},
});
export const paddleBillingInvitationsPolicy =
definePolicy<FeaturePolicyInvitationContext>({
id: 'paddle-billing',
stages: ['preliminary', 'submission'],
evaluate: async ({ subscription }) => {
if (!subscription) return allow();
if (subscription.provider === 'paddle' && subscription.status === 'trialing') {
const hasPerSeatItems = subscription.items.some((item) => item.type === 'per_seat');
if (hasPerSeatItems) {
return deny({
code: 'PADDLE_TRIAL_RESTRICTION',
message: 'teams.policyErrors.paddleTrialRestriction',
remediation: 'teams.policyRemediation.paddleTrialRestriction',
});
}
}
return allow();
},
});
// 2. Register them in a feature-scoped registry.
export const invitationPolicyRegistry = createPolicyRegistry();
invitationPolicyRegistry.register(subscriptionRequiredInvitationsPolicy);
invitationPolicyRegistry.register(paddleBillingInvitationsPolicy);// packages/features/team-accounts/src/server/policies/create-invitations-policy-evaluator.ts
import { createPoliciesEvaluator } from '@app/authz';
export function createInvitationsPolicyEvaluator() {
const evaluator = createPoliciesEvaluator<FeaturePolicyInvitationContext>();
return {
hasPoliciesForStage(stage: 'preliminary' | 'submission') {
return evaluator.hasPoliciesForStage(invitationPolicyRegistry, stage);
},
canInvite(context: FeaturePolicyInvitationContext, stage: 'preliminary' | 'submission') {
return evaluator.evaluate(invitationPolicyRegistry, context, 'ALL', stage);
},
};
}Consumed in the loader (preliminary stage) and in the action (submission stage):
// Loader: tell the UI whether the form should even render in active state.
const invitationEvaluator = createInvitationsPolicyEvaluator();
if (await invitationEvaluator.hasPoliciesForStage('preliminary')) {
const result = await invitationEvaluator.canInvite(invitationContext, 'preliminary');
if (!result.allowed) {
return { canInvite: false, reason: result.reasons[0] };
// UI renders the form disabled with the remediation suggestion.
}
}// Action: re-check at submit (the context may have changed since page load).
'use server';
export const inviteMemberAction = authActionClient
.inputSchema(InviteMemberSchema)
.action(async ({ parsedInput, ctx: { user } }) => {
const invitationEvaluator = createInvitationsPolicyEvaluator();
if (await invitationEvaluator.hasPoliciesForStage('submission')) {
const result = await invitationEvaluator.canInvite(invitationContext, 'submission');
if (!result.allowed) {
return { error: true, message: result.reasons[0]?.message };
}
}
// ... actual invitation work
});The two-stage pattern is the killer feature. preliminary runs when the page loads (so the form can be disabled with a helpful message); submission runs when the action fires (in case state changed in between — subscription expired, plan downgraded). Same policies, two evaluation points, one source of truth.
`allow()` vs `deny({ code, message, remediation })`:
| Returned | When | What the UI does |
|---|---|---|
allow() | Policy passes | Form is enabled, action proceeds |
deny({ code, message }) | Policy fails, no recommended fix | Block the action, show <Trans i18nKey={message} /> |
deny({ code, message, remediation }) | Policy fails AND there's a fix | Block + show remediation as a CTA link |
`'ALL'` operator: every policy in the registry must allow() for the result to be allowed. The first deny() short-circuits the rest (or all run, depending on evaluator settings) and the reasons are aggregated.
Where this complements data-layer authorization, not replaces it:
| Concern | Where it lives |
|---|---|
| "Can user X read row Y?" (row visibility) | Data-layer scoping (Postgres RLS here; a repository elsewhere) |
| "Can this account use feature Z given their plan?" (business rule) | @app/authz |
| "Has the user authenticated?" (session) | requireUser in the action/proxy |
The data layer answers visibility; policies answer business eligibility. A user with an expired subscription can still see their accounts (the data layer allows it) but the policy denies inviting new members.
Transferable: @app/authz is just declarative rules + an evaluator — the same shape whether you back it with hand-written definePolicy functions or a CASL-style ability builder. Keep row-visibility in the data layer and reserve this layer for business eligibility.
When NOT to use the policy layer:
- Trivial single-condition checks.
if (!user.isPro) return;doesn't need a registry. - One-off prerequisite checks inside a service. If the condition is genuinely local and never displayed to the UI, an inline check is simpler.
- Pure row-visibility checks the data layer already handles. Don't re-implement "can this user see this row."
Where this fits in a feature package:
packages/features/team-accounts/src/server/policies/invitation-policies.ts— invitation policies (subscription required, paddle trial restriction).packages/features/team-accounts/src/server/policies/create-account-policy-evaluator.ts— account creation gating.- Wired into the workspace loader (
apps/web/app/[locale]/home/(user)/_lib/server/load-user-workspace.ts) so the personal home page knows whether to render the "Create team" button enabled or with an upgrade prompt.
Reference: CASL: defining abilities
Hide Vendor SDKs Behind a Gateway Interface
Give your billing/mail/CMS/monitoring concerns the same shape: a core package defines the interface and shared schema, a gateway (or registry) resolves the active provider from config, and per-provider packages contain the vendor-specific SDK code. App code talks to the gateway, never the SDK. Switching from Stripe to Lemon Squeezy becomes a config change instead of a codebase change; adding a new provider means implementing the interface, not editing every consumer.
Incorrect (consumer imports the SDK directly — locked in):
// apps/web/app/[locale]/home/[account]/billing/page.tsx
import Stripe from 'stripe'; // ❌ Concrete vendor import.
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);
const checkoutSession = await stripe.checkout.sessions.create({
payment_method_types: ['card'],
// ... Stripe-specific shape leaks into product code
});
// 50 files later, same import everywhere.
// "Let's evaluate Lemon Squeezy" → 50-file diff.Correct (consumer imports the gateway — provider-agnostic):
// apps/web/app/[locale]/home/[account]/billing/page.tsx
import { getBillingGatewayProvider } from '@app/billing-gateway';
import { getServerClient } from '@app/supabase/server';
const provider = await getBillingGatewayProvider(getServerClient());
const { url } = await provider.createCheckoutSession({
accountId,
customerId,
plan: { variantId: 'pro-monthly' },
// ... shape defined by @app/billing-core, not by any one vendor
});
// Switching to Lemon Squeezy: change billing.config.ts, restart. No code change.The structure of the abstraction:
packages/billing/
├── core/ # The interface + shared types.
│ └── src/
│ ├── billing-schema.ts # Plan, Customer, Subscription, CheckoutSession types
│ └── billing-provider.interface.ts
├── gateway/ # Reads config, returns the active provider.
│ └── src/
│ └── get-billing-gateway-provider.ts
├── stripe/ # Stripe implementation (only place Stripe SDK is imported).
│ └── src/
│ └── stripe-billing-provider.ts
└── lemon-squeezy/ # Lemon Squeezy implementation.
└── src/
└── lemon-squeezy-billing-provider.tsThe gateway's resolution logic:
// packages/billing/gateway/src/get-billing-gateway-provider.ts
import billingConfig from '~/config/billing.config';
export async function getBillingGatewayProvider(client: DataClient) {
switch (billingConfig.provider) {
case 'stripe': {
const { createStripeBillingProvider } = await import('@app/billing-stripe');
return createStripeBillingProvider(client);
}
case 'lemon-squeezy': {
const { createLemonSqueezyBillingProvider } = await import('@app/billing-lemon-squeezy');
return createLemonSqueezyBillingProvider(client);
}
default:
throw new Error(`Unknown billing provider: ${billingConfig.provider}`);
}
}Why dynamic import per provider: only the active provider's SDK is loaded. Stripe's SDK is ~500KB; bundling both wastes startup time on every server cold start.
Each provider implements the same interface:
// packages/billing/core/src/billing-provider.interface.ts
export interface BillingProvider {
createCheckoutSession(params: CreateCheckoutParams): Promise<CheckoutSession>;
cancelSubscription(subscriptionId: string): Promise<void>;
getCustomer(customerId: string): Promise<Customer>;
handleWebhookEvent(request: Request): Promise<void>;
}Where the pattern pays off across concerns:
| Concern | Core package | Providers |
|---|---|---|
| Billing | packages/billing/core (@app/billing-core) | stripe, lemon-squeezy |
packages/mailers/core (@app/mailers-core) | nodemailer, resend | |
| CMS | packages/cms/core (@app/cms-core) | keystatic, wordpress |
| Monitoring | packages/monitoring/* (@app/monitoring) | sentry, baselime, etc. |
When the abstraction breaks: if you find yourself adding a method to the interface that only one provider supports (e.g., getStripeSubscriptionMetadata), the abstraction is leaking. Either find an equivalent in the other providers, expose it as an optional capability the gateway can advertise, or keep it inside the Stripe-only code path and don't generalize.
When to skip this pattern: for a single-provider concern that's unlikely to ever swap (e.g., your internal feature flag service), the abstraction adds layers without benefit. Apply it where swap is plausible: billing, email, CMS, observability.
Reference: Turborepo internal packages
Gate the Privileged Client Behind an Authorization Check Done Before You Construct It
The privileged client (service-role key) is the only escape hatch from your data-layer authorization — once constructed, the caller can read or mutate any tenant's rows. Authorize before you construct it: run an explicit check (isSuperAdmin(), a feature-specific guard, or a verified webhook signature) and only then reach for the privileged client. The safest way to make that ordering impossible to forget is to bake the guard into a dedicated adminActionClient, so the check always runs before the handler body.
Incorrect (privileged operation with no super-admin guard):
'use server';
import { getServiceRoleClient } from '@app/supabase/admin';
import { authActionClient } from '@app/next/safe-action';
import { BanUserSchema } from './ban-user.schema';
// authActionClient only proves the caller is signed in — so any
// authenticated user can call this and ban anyone.
export const banUserAction = authActionClient
.inputSchema(BanUserSchema)
.action(async ({ parsedInput: { userId } }) => {
const admin = getServiceRoleClient();
await admin.auth.admin.updateUserById(userId, { ban_duration: '876000h' });
});Correct (compose an admin action client that checks authorization first):
// @app/next/admin-action-client.ts — a thin layer you own on top of
// authActionClient; the guard runs before any handler body executes.
import 'server-only';
import { authActionClient } from '@app/next/safe-action';
import { isSuperAdmin } from '@app/authz';
import { getServerClient } from '@app/supabase/server';
export const adminActionClient = authActionClient.use(async ({ next, ctx }) => {
const isAdmin = await isSuperAdmin(getServerClient());
if (!isAdmin) {
throw new Error('Unauthorized'); // Thrown before the privileged client exists.
}
return next({ ctx }); // ctx.user is forwarded from authActionClient.
});// features/admin/users/server/ban-user-action.ts
'use server';
import { adminActionClient } from '@app/next/admin-action-client';
import { getServiceRoleClient } from '@app/supabase/admin';
import { BanUserSchema } from './ban-user.schema';
// Non-admins now get a thrown error before the handler runs, so by the
// time getServiceRoleClient() is called the caller is already authorized.
export const banUserAction = adminActionClient
.inputSchema(BanUserSchema)
.action(async ({ parsedInput: { userId } }) => {
const admin = getServiceRoleClient();
await admin.auth.admin.updateUserById(userId, { ban_duration: '876000h' });
});Correct (one-off privileged call inside an authenticated action) — still check first:
import { isSuperAdmin } from '@app/authz';
import { getServerClient } from '@app/supabase/server';
import { getServiceRoleClient } from '@app/supabase/admin';
// Construct the privileged client only after the guard passes.
if (!(await isSuperAdmin(getServerClient()))) {
throw new Error('Unauthorized');
}
const admin = getServiceRoleClient();Transferable: the rule is "the bypass path must be gated, and the gate runs before the bypass exists." With Postgres the bypass is the service-role client that ignores RLS; with another store it is any repository or connection that skips your scoping layer — wrap it in the same check-then-construct guard so the authorization can never be reordered after the privileged handle is in hand.
Reference: Supabase server-side auth for Next.js
Enforce MFA at the Proxy Boundary, Not Per-Page
MFA enforcement is a request-boundary concern, not a page concern. In Next.js 16 the boundary lives in proxy.ts (the renamed middleware.ts, exporting proxy and running on the Node.js runtime). Check MFA there once for every request under /home/* and redirect to the verify path if the session has not cleared it. Re-implementing the check in individual pages or layouts means new routes are unprotected by default, and one missed copy-paste leaks a protected route to a half-authenticated session.
Incorrect (MFA check copied into each protected page):
// app/[locale]/home/(user)/settings/page.tsx
export default async function SettingsPage() {
const client = getServerClient();
const requiresMfa = await checkRequiresMfa(client);
if (requiresMfa) {
redirect('/auth/verify');
}
// Now every protected page needs this block. A new page that forgets it
// is silently accessible to half-authenticated sessions.
}*Correct (one MFA gate in `proxy.ts` for `/home/`):**
// apps/web/proxy.ts — runs at the request boundary before any page renders.
import { NextResponse, type NextRequest } from 'next/server';
import { createMiddlewareClient, getUser } from '@app/supabase/middleware';
import { checkRequiresMfa } from '@app/supabase/mfa';
export async function proxy(request: NextRequest) {
const response = NextResponse.next();
const url = new URL(request.url);
if (!url.pathname.startsWith('/home')) return response;
const { data } = await getUser(request, response);
if (!data?.claims) {
// Unauthenticated requests never reach the page at all.
return NextResponse.redirect(new URL('/auth/sign-in', url.origin));
}
const client = createMiddlewareClient(request, response);
if (await checkRequiresMfa(client)) {
return NextResponse.redirect(new URL('/auth/verify', url.origin));
}
return response; // Pages under /home/* can trust the request cleared MFA.
}
export const config = { matcher: ['/home/:path*'] };The MFA verify page is the only exception. It must render for users who have not yet cleared MFA, so it explicitly opts out (e.g. requireUser(client, { verifyMfa: false })) rather than redirecting them back to itself.
Why this is more than a DRY argument: centralizing the gate means there is one place to audit, one place to log, and one place that decides what counts as "needs MFA." If the policy changes (say, MFA required only after first sign-in), you update one function. With per-page checks, the audit surface scales with the number of routes.
What still belongs in the page: authorization for specific actions on a page ("can this user delete this project?") — that is per-route business logic, not session-level enforcement.
Transferable: "enforce session-level posture at the request boundary, not in leaf pages." The Supabase AAL/MFA check is the concrete example here; with any auth provider, do the step-up check once in proxy.ts so newly added routes inherit the gate instead of opting into it.
Reference: Next.js proxy file convention
Mark Server-Only Modules with import 'server-only'
A server action, loader, privileged client, or service that accidentally gets imported by a client component will be tree-shaken into the client bundle along with everything it imports — including service-role keys and webhook secrets. import 'server-only' is a build-time poison pill: the import resolves on the server and fails the build on the client, catching the mistake before deploy.
Incorrect (privileged client with no server-only marker):
// packages/supabase/src/clients/admin.ts
import { createClient } from '@supabase/supabase-js';
import { getServiceRoleKey } from '../get-service-role-key';
// A client component that imports this by mistake compiles fine
// and ships the service-role-key-reading code to the browser.
export function getServiceRoleClient() {
const serviceRoleKey = getServiceRoleKey();
return createClient(process.env.NEXT_PUBLIC_SUPABASE_URL!, serviceRoleKey);
}Correct (build fails if a client component imports this file):
// packages/supabase/src/clients/admin.ts
import 'server-only';
import { createClient } from '@supabase/supabase-js';
import { getServiceRoleKey } from '../get-service-role-key';
export function getServiceRoleClient() {
const serviceRoleKey = getServiceRoleKey();
return createClient(process.env.NEXT_PUBLIC_SUPABASE_URL!, serviceRoleKey);
}Where this marker belongs:
| File pattern | Add import 'server-only'? |
|---|---|
**/clients/server.ts (request-scoped client) | Yes |
**/clients/admin.ts (privileged client) | Yes |
**/clients/middleware.ts | Yes |
**/server/**/*.ts (loaders, services) | Yes |
**/safe-action.ts (action client factory) | Yes |
**/routes/index.ts (route handler wrappers) | Yes |
Files containing the 'use server' directive | Optional (the directive already enforces this) |
| Files imported only by other server-only files | Inherited — not strictly required, but harmless |
Why this isn't paranoid: Next.js inlines process.env.NEXT_PUBLIC_* at build time. Anything else (SUPABASE_SERVICE_ROLE_KEY, STRIPE_SECRET_KEY) only exists on the server — but its reference in code, once bundled, throws at runtime and leaks the variable name. 'server-only' prevents the bundle from being attempted in the first place.
Reference: Next.js — keeping server-only code out of the client environment
Authorize Once at the Data Layer — Don't Re-Check the Same Rule in App Code
When you read through the request-scoped client, the policy on the table is already filtering every row to ones the user may see. Re-checking the same ownership rule in TypeScript creates two authorization surfaces that have to stay in sync, pulls more rows than necessary up to the application layer, and gives a false sense of safety — the policy is the boundary, and if it's wrong, the TypeScript check is racing it rather than backing it up.
Incorrect (fetch then filter in JS — drift + performance hit):
const client = getServerClient();
const { data: projects } = await client.from('projects').select('*');
// Drift: if RLS already filters by membership, this is redundant.
// If RLS does NOT filter, this is a SECURITY HOLE — the database is
// the source of truth and the full set may already be over the wire.
const visibleProjects = projects?.filter((p) => p.account_id === currentAccountId);Correct (trust the data layer — the query already returns only accessible rows):
const client = getServerClient();
// Policy: SELECT allowed if has_role_on_account(projects.account_id).
// Returns only rows the user is a member of — no JS filter needed.
const { data: projects } = await client.from('projects').select('*');Correct (use `.eq()` to scope further, not to authorize):
// Filtering to ONE specific account is a query refinement, not an
// authorization check. RLS still gates whether the user can see rows
// for THAT account at all.
const { data: projects } = await client
.from('projects')
.select('*')
.eq('account_id', selectedAccountId);When an app-layer check is genuinely correct:
- Before invoking the privileged client. RLS isn't filtering for the service-role client, so an
isSuperAdmin/hasPermissioncheck in code is the only line of defense. - Business-rule gating (not authorization): "can the user create more than N projects on their plan?" — a quota rule, not a row-visibility rule.
- Policy-engine messages (
definePolicyin@app/authz) — declarative rules that return user-facing copy (deny({ code, message, remediation })) layer cleanly on top of the data-layer check.
The worst version of this anti-pattern: someone, surprised that a query returned fewer rows than expected, switches to the privileged client to "fix" it and re-adds a JS filter as their authorization. They have just bypassed RLS entirely and replaced it with a check that misses the very edge case the policy handled.
Transferable: the principle is "authorize at the data layer, then trust it." With Postgres that boundary is RLS; with Drizzle or Prisma, enforce the same scoping in a repository or query helper every read passes through — and never re-implement that rule in a component as a second, drifting copy.
Reference: Supabase Row Level Security
Centralize the Auth Gate in One requireUser() Helper Instead of Scattering Raw Claim Checks
Build one requireUser() helper in @app/supabase (on top of @supabase/ssr's auth.getClaims()) that does three things atomically: validate the JWT, decide whether MFA is required for this account, and return the correct redirectTo for whichever check failed. Calling client.auth.getClaims() directly at each call site skips the MFA branch — a user with MFA enrolled but only AAL1 in their JWT passes the claim check and reaches protected pages. Scattering that raw call also means every loader re-derives the same redirect logic slightly differently.
Incorrect (raw claims check at the call site — skips MFA):
const client = getServerClient();
const { data, error } = await client.auth.getClaims();
if (!data?.claims || error) {
redirect('/auth/sign-in');
}
// data.claims.aal may be 'aal1' even though the user has MFA enrolled —
// this loader now serves protected data to a half-authenticated session.
const userId = data.claims.sub;Correct (delegate to the helper you own — discriminated union return):
import { requireUser } from '@app/supabase/require-user';
import { getServerClient } from '@app/supabase/server';
const client = getServerClient();
const auth = await requireUser(client);
if (auth.error) {
// redirectTo points to /auth/sign-in OR /auth/verify as appropriate.
redirect(auth.redirectTo);
}
// auth.data is typed — id, email, isSuperAdmin, aal, etc.
const userId = auth.data.id;The helper is a thin wrapper you own (@app/supabase/require-user.ts), built on @supabase/ssr:
import 'server-only';
import type { SupabaseClient } from '@supabase/supabase-js';
export async function requireUser(
client: SupabaseClient,
{ verifyMfa = true } = {},
) {
const { data, error } = await client.auth.getClaims();
if (error || !data?.claims) {
return { error: true, redirectTo: '/auth/sign-in' } as const;
}
// The single place that knows MFA enrolled + AAL1 means "not done yet".
if (verifyMfa && data.claims.aal === 'aal1' && data.claims.amr?.length) {
return { error: true, redirectTo: '/auth/verify' } as const;
}
return { error: false, data: { id: data.claims.sub, ...data.claims } } as const;
}Why the discriminated union matters: the narrowing forces you to handle the error case before reading auth.data. There is no way to silently use a stale or missing user.
`verifyMfa: false` only when you have a reason: the MFA verify page itself calls requireUser(client, { verifyMfa: false }) because it is the destination. Everywhere else the default true is what you want.
Pair this with the proxy MFA gate. See auth-mfa-in-middleware — enforcement happens at the request boundary in proxy.ts; requireUser() is the per-context helper that produces the right redirect target.
Where to call it:
| Context | Pattern |
|---|---|
| Server Component / loader | A wrapper around requireUser that performs the redirect |
| Server Action | Use authActionClient — it calls requireUser and injects ctx.user |
| Route Handler | A route wrapper whose auth: true calls requireUser |
| Webhook | auth: false — no user; verify a signature instead |
Transferable: the principle is "one chokepoint owns the auth-plus-step-up decision and the redirect target." Supabase claims and AAL are the concrete check here; with another provider, still funnel every protected read through a single helper so the MFA/session-posture branch can never be forgotten at a call site.
Reference: Supabase server-side auth for Next.js
Use Reusable SQL Helper Functions Inside RLS Policies
Define SECURITY DEFINER helper functions once (has_role_on_account, has_permission, is_account_owner, has_active_subscription, is_team_member, is_super_admin) and call them from every RLS policy so each table reads the same source of truth. Inline subqueries that duplicate membership logic drift out of sync — a change to permission semantics has to be applied in every policy that copied the subquery, and the one you miss is a silent authorization hole.
Incorrect (inline subquery duplicating membership logic):
create policy projects_select on public.projects
for select to authenticated using (
exists (
select 1 from public.accounts_memberships m
where m.user_id = (select auth.uid())
and m.account_id = projects.account_id
and m.account_role in ('owner', 'admin')
)
);
-- Every other tenant-scoped table now needs the same subquery; change the
-- permission model and you must find them all, or leave a hole behind.Correct (delegate to the centralized helper):
create policy projects_select on public.projects
for select to authenticated using (
public.has_role_on_account(projects.account_id)
);
-- For a specific role:
create policy projects_delete on public.projects
for delete to authenticated using (
public.has_role_on_account(projects.account_id, 'owner')
);
-- For permission-based access (preferred for fine-grained rules):
create policy projects_update on public.projects
for update to authenticated using (
public.has_permission((select auth.uid()), projects.account_id, 'projects.manage')
);*Helpers you define once and reuse (across `apps/web/supabase/schemas/.sql`):**
| Helper | Defined in | Use when |
|---|---|---|
has_role_on_account(account_id, role?) | 05-memberships.sql | Membership check, optionally for a specific role |
has_permission(user_id, account_id, permission) | 06-roles-permissions.sql | Fine-grained permission check |
is_account_owner(account_id) | 03-accounts.sql | Owner-only operations |
has_active_subscription(account_id) | 09-subscriptions.sql | Feature gating by billing state |
is_team_member(account_id, user_id) | 05-memberships.sql | Membership check by explicit user |
is_super_admin() | 13-mfa.sql | Super-admin escape hatch |
Why `SECURITY DEFINER`: these functions run with the privileges of their definer (postgres), so they can read accounts_memberships even when the calling user's own RLS would forbid it. set search_path = '' prevents schema-injection via search-path manipulation.
Transferable: the principle is "centralize scoping predicates in one place, whatever the store." With Postgres that place is a set of SECURITY DEFINER SQL functions called from policies; with Drizzle or Prisma, put the same membership/permission predicate in one repository or policy module every query composes — so a permission change touches one definition, not dozens of copies.
Reference: Supabase Row Level Security
Read Through the Request-Scoped Auth-Bound Client, Never the Privileged Client by Default
Build two server clients in @app/supabase and reach for the request-scoped one by default. The request-scoped client carries the caller's identity, so the data layer — Postgres RLS here — filters every row to what that user may see. The privileged client uses the service-role key and bypasses every policy; defaulting to it turns each query into a potential cross-tenant leak. Authorize at the data layer and trust it instead of re-deriving tenant checks in TypeScript.
Incorrect (service-role client for a routine read — bypasses RLS):
import { getServiceRoleClient } from '@app/supabase/admin';
export async function loadWorkspace() {
const client = getServiceRoleClient(); // service role: sees every account's rows
const { data } = await client.from('accounts').select('*').single();
return data;
}Correct (request-scoped client — identity-bound, RLS-filtered):
import { getServerClient } from '@app/supabase/server';
export async function loadWorkspace() {
const client = getServerClient(); // RLS filters to the caller's own accounts
const { data } = await client.from('accounts').select('*').single();
return data;
}The request-scoped client is a thin wrapper you own (@app/supabase/server.ts) — built on @supabase/ssr, never a vendored helper:
import 'server-only';
import { cookies } from 'next/headers';
import { createServerClient } from '@supabase/ssr';
import type { Database } from '@app/supabase/types';
export function getServerClient() {
const store = cookies(); // forwards the auth cookie, so RLS sees the user's JWT
return createServerClient<Database>(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
{
cookies: {
getAll: () => store.getAll(),
setAll: (items) =>
items.forEach(({ name, value, options }) => store.set(name, value, options)),
},
},
);
}When the privileged client is legitimate — always authorize before constructing it:
- Webhook handlers where there is no authenticated user (payment provider, DB webhook).
- Cross-tenant administrative reads inside a super-admin-guarded action.
- Pre-auth lookups (e.g. reading an invitation token before the invitee signs in).
Transferable: the principle is "queries run under the caller's authority, enforced at the data layer." With Postgres that boundary is RLS; with another store (Drizzle, Prisma) enforce the same scoping in a repository or policy that every read passes through — and keep a separate, explicitly-guarded path for privileged access.
Reference: Supabase server-side auth for Next.js
Pass Server Data to Client Components as Props, Don't Refetch
The server component already has the workspace/list/account from the loader. Passing it down as props means the client subtree renders immediately with the data — no loading spinner, no extra round-trip. Re-fetching the same data on the client via a browser data client + useQuery() doubles the round-trips (server fetched once, browser fetches again from the user's network) and shows the user a stale window during the second fetch.
Incorrect (server fetches, client throws it away and refetches):
// page.tsx (server)
export default async function Page() {
const client = getServerClient();
const { data: projects } = await client.from('projects').select('*');
// Server has the data, but...
return <ProjectsClient />; // Doesn't pass it down.
}// projects-client.tsx
'use client';
import { useQuery } from '@tanstack/react-query';
import { useClient } from '@app/supabase/client';
export function ProjectsClient() {
const client = useClient();
const { data: projects, isLoading } = useQuery({
queryKey: ['projects'],
queryFn: () => client.from('projects').select('*').then(r => r.data),
});
// Same query the server just ran — round-tripped again from the user's network.
if (isLoading) return <Spinner />; // User sees a flash of loading.
return <ProjectList projects={projects ?? []} />;
}Correct (server passes data, client uses it as initialData):
// page.tsx (server)
export default async function Page() {
const client = getServerClient();
const { data: projects } = await client.from('projects').select('*');
return <ProjectsClient initialProjects={projects ?? []} />;
}// projects-client.tsx
'use client';
import { useQuery } from '@tanstack/react-query';
export function ProjectsClient({ initialProjects }: { initialProjects: Project[] }) {
// For static-after-mount data: just use the prop.
return <ProjectList projects={initialProjects} />;
}Correct (initial render uses server data, then real-time / refetch takes over):
'use client';
import { useQuery } from '@tanstack/react-query';
import { useClient } from '@app/supabase/client';
export function NotificationsClient({
initialNotifications,
accountIds,
}: {
initialNotifications: Notification[];
accountIds: string[];
}) {
const client = useClient();
const { data: notifications } = useQuery({
queryKey: ['notifications', ...accountIds],
queryFn: async () => {
const { data } = await client
.from('notifications')
.select('*')
.in('account_id', accountIds)
.order('created_at', { ascending: false })
.limit(10);
return data ?? [];
},
initialData: initialNotifications, // No loading on first render.
refetchOnMount: false, // Server already gave us fresh data.
refetchOnWindowFocus: true, // Refetch when user returns to tab.
});
// Real-time subscription updates the cache on new events.
useNotificationsStream({
accountIds,
enabled: true,
onNotifications: (newOnes) => {
queryClient.setQueryData(['notifications', ...accountIds], (old: any) => [
...newOnes,
...(old ?? []),
]);
},
});
return <NotificationList items={notifications ?? []} />;
}When this matters most: above-the-fold data (the user sees a spinner if you refetch), workspace/account context that every component needs, lists that the user is about to interact with. The server already did the work — let it count.
`refetchOnMount: false` is the typical pairing. When initialData is provided, React Query considers it fresh by default (staleTime: 0 reloads it on mount; bumping staleTime or setting refetchOnMount: false keeps the server-rendered data).
Pass primitives or stable references. Passing a new useMemo object every render makes the query key unstable. For lists, pass the array; React Query handles equality.
Server data is the source of truth for the first render. Updates after that come from mutations + revalidation, or real-time subscriptions, or refocus refetches — but not from "let's re-ask for the same data the page already had."
Reference: TanStack Query initialData
Tear Down Any Subscription or Event Source in the useEffect Return
Any long-lived connection opened inside an effect — a realtime channel, a raw WebSocket, an EventSource, a DOM listener — must be closed in the effect's cleanup function. Without it, every dep change leaves an orphaned source: handlers fire multiple times per event, the connection count climbs (most providers have per-client limits), and unmounting the component does nothing. The pattern: open the source in the effect body, return () => source.close().
Incorrect (no cleanup — channels leak on every dep change):
'use client';
import { useEffect, useState } from 'react';
import { useClient } from '@app/supabase/client';
export function NotificationsLive({ accountIds }: { accountIds: string[] }) {
const client = useClient();
const [latest, setLatest] = useState<Notification | null>(null);
useEffect(() => {
const channel = client.channel('notifications');
channel
.on('postgres_changes',
{ event: 'INSERT', schema: 'public', table: 'notifications',
filter: `account_id=in.(${accountIds.join(', ')})` },
(payload) => setLatest(payload.new as Notification),
)
.subscribe();
// No return → no cleanup.
// accountIds changes → new channel opened on top of the old one.
// Each INSERT now triggers 2, 3, N handlers.
// Component unmounts → channel stays open until GC, possibly forever.
}, [accountIds]);
return latest && <Toast>{latest.body}</Toast>;
}Correct (canonical pattern — explicit cleanup):
// packages/features/notifications/src/hooks/use-notifications-stream.ts
'use client';
import { useEffect } from 'react';
import { useClient } from '@app/supabase/client';
export function useNotificationsStream({
onNotifications,
accountIds,
enabled,
}: {
onNotifications: (notifications: Notification[]) => void;
accountIds: string[];
enabled: boolean;
}) {
const client = useClient();
useEffect(() => {
if (!enabled) return;
const channel = client.channel('notifications-channel');
const subscription = channel
.on(
'postgres_changes',
{
event: 'INSERT',
schema: 'public',
filter: `account_id=in.(${accountIds.join(', ')})`,
table: 'notifications',
},
(payload) => {
onNotifications([payload.new as Notification]);
},
)
.subscribe();
return () => {
// Cleanup: fires on unmount AND before re-running the effect.
void subscription?.unsubscribe();
};
}, [client, onNotifications, accountIds, enabled]);
}`removeChannel` vs `unsubscribe`: for a single channel, subscription.unsubscribe() is sufficient. If you held multiple channels by name, use client.removeChannel(channel) for each. Don't use client.removeAllChannels() — that breaks unrelated subscriptions in the same client.
Stable callback references. If onNotifications is recreated every render, the effect re-runs every render — open, close, open, close on every keypress. Wrap it in useCallback at the call site, or use a useRef for handlers that don't need to be reactive:
// In the consumer:
const onNotifications = useCallback((newOnes: Notification[]) => {
setNotifications((prev) => [...newOnes, ...prev]);
}, []);
useNotificationsStream({ onNotifications, accountIds, enabled: true });Filter strings depend on `accountIds`. Joining the array on every render creates a new string; if accountIds is the dep, fine — the effect re-runs only on actual list changes. If you pass the joined string as a dep, the same trap applies.
Conditional subscription with `enabled`. Putting the if (!enabled) return; inside the effect (rather than guarding the hook call) keeps the hook unconditional — Rules of Hooks require it. The cleanup still runs on enabled flipping false.
Don't subscribe inside the component body. Subscribing on every render (outside useEffect) creates a new channel every render and never cleans up. Subscriptions always belong in useEffect.
Watch for channel name reuse. client.channel('notifications') returns the same channel object for the same name across the app. If two components both call .channel('notifications') and only one unsubscribes, the other one's handlers stop firing because the channel was torn down. Use unique names per consumer ('notifications-' + componentId) when channels aren't intentionally shared.
Transferable: the example uses a Supabase realtime channel, but the rule is "every source you open in an effect, you close in its cleanup." A raw new WebSocket(url) returns () => socket.close(); an EventSource returns () => source.close(); addEventListener returns () => removeEventListener(...). Same lifecycle, different API.
Reference: Supabase Realtime docs
Use Stable, Hierarchical Query Keys
A React Query cache entry is keyed by a deep-equal comparison of its key array. ['notifications'] returns the same cached data regardless of which account is being viewed — switching tenants shows the previous tenant's notifications until the cache expires. ['notifications', ...accountIds] keys per account. New objects created in component render cause every render to look like a different key — pass primitives, sorted arrays, or stable references.
Incorrect (key too coarse — cache collisions across contexts):
function useNotifications({ accountIds }: { accountIds: string[] }) {
const client = useClient();
return useQuery({
queryKey: ['notifications'], // SAME key for every accountIds value.
queryFn: () =>
client.from('notifications').select('*').in('account_id', accountIds),
});
}
// User switches from /home/acme to /home/beta:
// Same query key → React Query returns the cached acme data.
// User sees acme's notifications under beta's URL.Incorrect (key includes an unstable object — refetches on every render):
function useNotifications({ accountIds, filter }: Props) {
const client = useClient();
return useQuery({
// New object every render → keys are never deep-equal → cache miss every time.
queryKey: ['notifications', { accountIds, filter }],
queryFn: () => /* ... */,
});
}
// Every render triggers a refetch. Look-busy UI, wasted bandwidth.Correct (hierarchical key with primitives and stable values):
function useNotifications({ accountIds, filter }: Props) {
const client = useClient();
return useQuery({
// Primitives in a flat array. Equal sub-arrays compare equal.
// `accountIds` should already be a stable reference from a stable source
// (e.g., workspace loader result), or sort it before passing.
queryKey: ['notifications', filter, ...accountIds],
queryFn: () => /* ... */,
});
}Hierarchy convention (matches React Query's invalidation patterns):
['accounts'] // All account queries.
['accounts', accountId] // One account's queries.
['accounts', accountId, 'projects'] // That account's projects.
['accounts', accountId, 'projects', { archived: false }] // Filtered.This pays off at invalidation time:
queryClient.invalidateQueries({ queryKey: ['accounts', accountId] });
// Invalidates: ['accounts', accountId], ['accounts', accountId, 'projects'],
// ['accounts', accountId, 'projects', anything] — every key starting with this prefix.Sort spread arrays for stable equality. [...accountIds] is order-sensitive. If two consumers might pass the same set in different orders, sort before spreading:
queryKey: ['notifications', filter, ...[...accountIds].sort()],Or wrap the call in a single useMemo if the sort cost matters:
const sortedIds = useMemo(() => [...accountIds].sort(), [accountIds]);
queryKey: ['notifications', filter, ...sortedIds];Co-locate keys with the hook. Don't sprinkle bare arrays through call sites — define a key factory next to the hook:
// keys.ts
export const notificationKeys = {
all: () => ['notifications'] as const,
byAccount: (accountId: string) => ['notifications', accountId] as const,
byAccountFiltered: (accountId: string, filter: string) =>
['notifications', accountId, filter] as const,
};
// Use site:
useQuery({ queryKey: notificationKeys.byAccount(accountId), queryFn });
// Invalidation site (in a mutation onSuccess):
queryClient.invalidateQueries({ queryKey: notificationKeys.byAccount(accountId) });`enabled: !!accountId` for late-arriving deps. If a dep is undefined on first render (e.g., loading from a parent context), passing it through to the key creates a ['notifications', undefined] cache entry. Set enabled to gate execution:
useQuery({
queryKey: ['notifications', accountId],
queryFn: () => /* ... */,
enabled: !!accountId,
});Default `staleTime` is 0 — refetches on every mount. That's the cause of "why does my query refetch every time I navigate?" Set a staleTime (e.g., 5_000 for a few seconds, Infinity for "until invalidated") so data persists across navigations.
Reference: TanStack Query: query keys
Call Server Actions with useAction from next-safe-action/hooks
useAction(action) returns { execute, executeAsync, isPending, result, ... } — execute is typed by the action's Zod schema (wrong input shape fails to compile), isPending tracks the request, result holds .data on success and .serverError on failure, and onSuccess/onError callbacks let you toast or navigate. Calling actions with raw fetch (or even startTransition around the bare function) loses every guarantee — types, loading state, error envelopes, and the standardised result shape.
Incorrect (raw fetch / bare action call — reimplementing everything):
'use client';
export function ContactForm() {
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const onSubmit = async (data: ContactInput) => {
setLoading(true);
setError(null);
try {
// Direct call to a 'use server' function: bypasses safe-action's pipeline.
// No client-side validation, no typed result envelope, easy to misuse.
const res = await sendContactEmail(data);
if (!res?.ok) setError('Failed');
} catch (e) {
setError('Failed');
} finally {
setLoading(false);
}
};
// Now do this for every form. Each one with subtly different error handling.
}Correct (canonical pattern — `useAction` provides all of it):
// app/[locale]/(marketing)/contact/_components/contact-form.tsx
'use client';
import { useAction } from 'next-safe-action/hooks';
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { ContactEmailSchema } from '../_lib/contact-email.schema';
import { sendContactEmail } from '../_lib/server/server-actions';
export function ContactForm() {
const [state, setState] = useState({ success: false, error: false });
const { execute, isPending } = useAction(sendContactEmail, {
onSuccess: () => setState({ success: true, error: false }),
onError: () => setState({ error: true, success: false }),
});
const form = useForm({
resolver: zodResolver(ContactEmailSchema),
defaultValues: { name: '', email: '', message: '', captchaToken: '' },
});
return (
<Form {...form}>
<form
onSubmit={form.handleSubmit((data) => execute(data))}
>
{/* fields with FormMessage */}
<Button type="submit" disabled={isPending}>
{isPending ? <Spinner /> : <Trans i18nKey="contact.submit" />}
</Button>
</form>
</Form>
);
}Why `execute` and not `executeAsync`:
| execute(input) | Fires the action. Doesn't return a promise you await. Use with onSuccess/onError. | | executeAsync(input) | Returns a promise. Use when you need to await (e.g., to chain actions or use the result inline). |
For form submissions, execute + callbacks is the idiomatic pattern. For "do A, then do B with A's result," executeAsync.
`isPending` for the button state:
<Button type="submit" disabled={isPending}>
{isPending ? <Spinner /> : 'Submit'}
</Button>Don't toggle a local useState for this — isPending is already correctly debounced and tracks the safe-action lifecycle.
`result.serverError` for displaying failures:
const { execute, result } = useAction(action, {
onError: ({ error }) => {
if (error.serverError) toast.error(error.serverError);
if (error.validationErrors) /* field-level errors */;
},
});
// Or render inline:
{result?.serverError && <Alert variant="destructive">{result.serverError}</Alert>}Typed input — no `as any` needed:
// The action defines: .inputSchema(ContactEmailSchema)
// execute is typed as: (input: z.input<typeof ContactEmailSchema>) => void
execute({ name: 'Pedro', email: 'p@p.com', message: '...', captchaToken: '...' });
// ^^^^^^^^^^^^^^^ wrong type here is a compile errorForm-level integration: use form.handleSubmit((data) => execute(data)). The Zod schema is the same on both sides (see the schema-separate-file rule), so the data is already validated by RHF when it reaches execute.
One `useAction` per action. Resist the urge to wrap multiple actions in one hook — the isPending state would be ambiguous. One hook per mutation is the clean shape.
Optimistic updates: combine with React Query's useMutation cache-update pattern, OR use safe-action v8+'s optimisticData parameter:
const { execute } = useAction(toggleStarAction, {
optimisticData: (input) => ({ starred: input.value }),
});Reference: next-safe-action `useAction`
Mark 'use client' at Leaf Components, Not Page Roots
'use client' is a boundary marker — the file with the directive, and every component imported by it, ships to the browser and runs client-side. Putting it at the page or layout level forces the entire subtree (including the server-only data fetching) into the bundle and hydrates components that never needed JavaScript. The pattern: server components own structure and data; only the leaf components that actually need state, effects, or event handlers get 'use client'.
Incorrect (`'use client'` at the page level — whole tree bundled):
// app/[locale]/home/(user)/projects/page.tsx
'use client';
import { useState } from 'react';
import { useClient } from '@app/supabase/client';
import { useQuery } from '@tanstack/react-query';
import { ProjectFilter } from './_components/project-filter';
import { ProjectCard } from './_components/project-card';
import { PageHeader } from './_components/page-header'; // Static, but now in client bundle.
import { EmptyState } from './_components/empty-state'; // Static, but now in client bundle.
export default function ProjectsPage() {
const client = useClient();
const { data: projects } = useQuery({
queryKey: ['projects'],
queryFn: () => client.from('projects').select('*').then(r => r.data),
});
const [filter, setFilter] = useState('');
return (
<>
<PageHeader title="Projects" />
<ProjectFilter value={filter} onChange={setFilter} />
{!projects?.length ? <EmptyState /> : projects.map((p) => <ProjectCard key={p.id} project={p} />)}
</>
);
}
// Bundle includes: ProjectsPage, ProjectFilter, ProjectCard, PageHeader, EmptyState,
// useClient, useQuery, the entire browser data SDK — all shipped to the user.Correct (server page composes; only the interactive piece is client):
// app/[locale]/home/(user)/projects/page.tsx
// No 'use client' — this is a server component.
import { getServerClient } from '@app/supabase/server';
import { PageHeader } from './_components/page-header';
import { EmptyState } from './_components/empty-state';
import { ProjectList } from './_components/project-list'; // The one client island.
export default async function ProjectsPage() {
const client = getServerClient();
const { data: projects } = await client.from('projects').select('*');
return (
<>
<PageHeader title="Projects" />
{!projects?.length ? <EmptyState /> : <ProjectList initialProjects={projects} />}
</>
);
}// app/[locale]/home/(user)/projects/_components/project-list.tsx
'use client';
// ONLY this file (and what it imports) ends up in the client bundle.
import { useState } from 'react';
import { ProjectFilter } from './project-filter';
import { ProjectCard } from './project-card';
export function ProjectList({ initialProjects }: { initialProjects: Project[] }) {
const [filter, setFilter] = useState('');
const filtered = initialProjects.filter((p) => p.name.toLowerCase().includes(filter.toLowerCase()));
return (
<>
<ProjectFilter value={filter} onChange={setFilter} />
{filtered.map((p) => <ProjectCard key={p.id} project={p} />)}
</>
);
}The rule of thumb: if a component uses useState, useEffect, an event handler (onClick, onChange), or a React Query hook, it's a client component. If it just renders props/children, it's a server component. Push 'use client' as deep as possible.
`PageHeader`, `EmptyState`, layouts, decorative wrappers — none of these need 'use client'. Even if they're imported by a client component, they remain server components unless they have the directive themselves OR are imported into a client file. The boundary is per-file.
Client components can render server components as children (via props/children), so server data can pass through a client wrapper:
// app/[locale]/home/(user)/page.tsx (server)
<DismissableBanner> {/* client component for the dismiss interaction */}
<ServerRenderedContent /> {/* still server-rendered, passed as children */}
</DismissableBanner>Don't fight this with `dynamic({ ssr: false })`. That's a different escape hatch (skipping SSR for a specific component, e.g., for browser-only APIs). It doesn't replace correct 'use client' placement.
Reference: React `'use client'` directive
Pair a Memoized Browser Data Client with TanStack Query for Client-Side Reads
Expose one memoized browser data client per tree (so every component shares a single instance) and wrap every read in useQuery, keyed by [resource, ...params]. The shared client avoids re-instantiating the SDK; the query key lets two components asking for the same data trigger a single network call. TanStack Query then gives you loading/error states, refetch-on-focus, in-flight deduplication, optimistic updates, and cache invalidation — everything you would otherwise hand-roll inside useEffect.
Incorrect (raw `useEffect` + `useState` per consumer — every component fetches):
'use client';
import { useEffect, useState } from 'react';
import { useClient } from '@app/supabase/client';
export function NotificationBell() {
const [count, setCount] = useState(0);
const client = useClient();
useEffect(() => {
client
.from('notifications')
.select('id', { count: 'exact' })
.eq('dismissed', false)
.then((response) => setCount(response.count ?? 0));
}, [client]);
// Header bell fetches. Sidebar bell ALSO fetches. Dashboard counter ALSO fetches.
return <Bell count={count} />;
}Correct (memoized client + `useQuery` with a stable key):
// packages/features/notifications/src/hooks/use-fetch-notifications.ts
import { useQuery } from '@tanstack/react-query';
import { useClient } from '@app/supabase/client';
export function useFetchNotifications(props: { accountIds: string[] }) {
const client = useClient();
const now = new Date().toISOString();
return useQuery({
queryKey: ['notifications', ...props.accountIds], // Same key → single fetch.
queryFn: async () => {
const { data } = await client
.from('notifications')
.select(`id, body, dismissed, type, created_at, link`)
.in('account_id', props.accountIds)
.eq('dismissed', false)
.gt('expires_at', now)
.order('created_at', { ascending: false })
.limit(10);
return data ?? [];
},
refetchOnMount: false,
refetchOnWindowFocus: false,
});
}// Multiple consumers — all share one fetch.
function NotificationBell({ accountIds }: { accountIds: string[] }) {
const { data } = useFetchNotifications({ accountIds });
return <Bell count={data?.length ?? 0} />;
}
function NotificationList({ accountIds }: { accountIds: string[] }) {
const { data } = useFetchNotifications({ accountIds });
return <List items={data ?? []} />;
}
// Both consumers → TanStack Query deduplicates to one underlying client.from('notifications').What you get for free:
| Concern | Manual useEffect | useQuery |
|---|---|---|
| Loading state | useState(true) + setLoading false in .then | isPending / isLoading |
| Error state | try/catch + useState | isError, error |
| Refetch on focus | addEventListener('focus') + cleanup | refetchOnWindowFocus: true (default) |
| Dedup across consumers | Lift state to a Context | Built-in by query key |
| Pagination | Manual offset state + array merge | useInfiniteQuery |
| Stale-while-revalidate | Two pieces of state (current + revalidating) | data + isFetching |
| Mutations + cache update | Manual setQueryData everywhere | useMutation + onSuccess: invalidateQueries |
Mutation hook pattern (same memoized client, `useMutation`):
import { useMutation } from '@tanstack/react-query';
import { useClient } from '@app/supabase/client';
import type { Database } from '@app/supabase/types';
export function useUpdateAccountData(accountId: string) {
const client = useClient();
return useMutation({
mutationKey: ['account:data', accountId],
mutationFn: async (changes: Database['public']['Tables']['accounts']['Update']) => {
const response = await client.from('accounts').update(changes).match({ id: accountId });
if (response.error) throw response.error; // Surface the failure to onError.
return response.data;
},
});
}The browser client is a thin hook you own (@app/supabase/client.ts) — built on the @supabase/ssr browser client and memoized so every call returns the same instance:
import { useMemo } from 'react';
import { createBrowserClient } from '@supabase/ssr';
import type { Database } from '@app/supabase/types';
export function useClient() {
// One instance per tree: re-creating the SDK each render would re-open connections.
return useMemo(
() =>
createBrowserClient<Database>(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
),
[],
);
}Stable query keys: ['notifications'] is too coarse — switching accounts shows the previous account's data. ['notifications', accountId] is keyed per account. For lists with multiple filters, include them all: ['projects', accountId, filter, sort].
Don't `useMemo` the client at the call site. useClient() already returns a memoized instance; calling it in every component is correct — each call returns the same object.
Server actions for mutations, TanStack Query for reads. The action is the authoritative write path (auth, validation, logging, revalidatePath); TanStack Query handles the read side and invalidation. Don't read via server actions; don't write via raw client queries from the browser.
Transferable: the pattern is "one memoized browser client + a typed-key query cache." The Supabase browser client is the concrete example; with Drizzle/Prisma over a fetch-based API route, the queryFn calls your endpoint instead — the dedup, caching, and key hierarchy are identical.
Reference: TanStack Query docs
Wrap Route Handlers in a Typed Handler That Owns Auth and Validation
Build one wrapper in @app/next/route-handler and route every API handler through it. The wrapper layers opt-in auth, optional CAPTCHA verification, Zod body parsing, and awaited params onto a plain Next.js Route Handler, then hands your handler a typed { request, body, user, params } argument. A bare exported POST forces each route to re-implement the same try/catch around the auth check, manually parse the body, read the CAPTCHA header, and turn validation failures into a consistent response. Multiply that across twenty routes and you have twenty subtly different auth implementations — one of which will eventually forget the check.
Incorrect (bare route handler — the same boilerplate, copied unevenly):
// app/api/projects/route.ts
export async function POST(request: NextRequest) {
// Auth — easy to forget on the next handler you add.
const client = getServerClient();
const { data } = await client.auth.getClaims();
if (!data?.claims) return new Response('Unauthorized', { status: 401 });
// Body parsing — could throw uncaught and surface as a 500.
let body;
try {
body = await request.json();
} catch {
return new Response('Invalid JSON', { status: 400 });
}
// Validation — a different error shape than every other route.
if (!body.name || typeof body.name !== 'string') {
return new Response(JSON.stringify({ error: 'name required' }), { status: 400 });
}
// ... the actual work ...
}Correct (one wrapper supplies auth, validation, and typed params):
// app/api/projects/route.ts
import { enhanceRouteHandler } from '@app/next/route-handler';
import { CreateProjectSchema } from '@app/projects/schema';
import { createProjectsService } from '@app/projects/server';
import { getServerClient } from '@app/supabase/server';
import { NextResponse } from 'next/server';
export const POST = enhanceRouteHandler(
async ({ body, user }) => {
// body is typed as z.output<typeof CreateProjectSchema> — fields auto-complete.
// user is already authenticated, so the handler never re-checks the session.
const service = createProjectsService(getServerClient());
const project = await service.create({ ...body, userId: user.id });
return NextResponse.json(project);
},
{
schema: CreateProjectSchema, // Validates the body, returns 400 on failure.
auth: true, // Default; runs the auth check, redirects on fail.
captcha: false, // Set true to verify the x-captcha-token header.
},
);You own the wrapper (packages/next/src/route-handler.ts) — a thin layer over a Next.js Route Handler, not a vendored helper:
import 'server-only';
import { NextResponse, type NextRequest } from 'next/server';
import { redirect } from 'next/navigation';
import type { ZodType } from 'zod';
import { requireUser } from '@app/supabase/require-user';
import { getServerClient } from '@app/supabase/server';
import { verifyCaptchaToken } from '@app/captcha/server';
export function enhanceRouteHandler<Body>(
handler: (args: { request: NextRequest; body: Body; user: User; params: Record<string, string> }) => Promise<Response>,
config: { schema?: ZodType<Body>; auth?: boolean; captcha?: boolean } = {},
) {
return async function routeHandler(
request: NextRequest,
context: { params: Promise<Record<string, string>> },
) {
if (config.captcha) {
await verifyCaptchaToken(request.headers.get('x-captcha-token') ?? ''); // 400 if missing/invalid.
}
let user: User | undefined;
if (config.auth ?? true) {
const auth = await requireUser(getServerClient());
if (auth.error) redirect(auth.redirectTo); // No session → sign-in route.
user = auth.data;
}
let body = undefined as Body;
if (config.schema) {
const parsed = await config.schema.safeParseAsync(await request.clone().json());
if (!parsed.success) {
return NextResponse.json({ error: parsed.error.message }, { status: 400 });
}
body = parsed.data;
}
// Next 16's params is a Promise — await it once so handlers read fields synchronously.
return handler({ request, body, user: user!, params: await context.params });
};
}Config matrix for common route types:
| Route type | auth | captcha | schema | Notes |
|---|---|---|---|---|
| Authenticated mutation | true (default) | false | required | Most in-app API routes |
| Public form submission | false | true | required | Marketing forms, signups |
| Webhook (payment, DB) | false | false | optional | Verify the provider signature inside the handler |
| Authenticated read | true (default) | false | optional | Reads with no body |
| File upload | true (default) | false | optional | Schema doesn't fit multipart — parse inside |
Params are awaited for you. Next 16's params is a Promise; the wrapper awaits it before calling your handler, so you read params.id instead of (await params).id in every route.
Don't mix bare handlers and the wrapper in one project. Pick one and apply it universally. A contributor opening any app/api/.../route.ts should find the same wrapping pattern as every other route.
Keep the validation error shape stable so clients can rely on error being the message:
{ "error": "name: String must contain at least 1 character" }If you need field-level errors, build a small mapper from z.ZodError — but keep the top-level error consistent.
Transferable: the wrapper enforces "auth and validation happen in one place, before the handler runs." The example reads the session from Supabase, but the same wrapper works with any auth source — swap requireUser for your session check and the contract your handlers see ({ request, body, user, params }) stays identical.
Reference: Next.js Route Handlers
Related skills
FAQ
What does opinionated-nextjs-patterns do?
opinionated-nextjs-patterns is a Claude Code skill for ai & agent building. It helps developers move faster with AI-assisted coding.
When should I use opinionated-nextjs-patterns?
When you need to helps with ai & agent building tasks during ai-assisted development, or when opinionated-nextjs-patterns is a claude code skill for ai & agent building. it helps developers move faster with ai-assisted coding.
What are the main capabilities?
opinionated-nextjs-patterns; AI & Agent Building; AI-coding skill.