Home/Projects/Wharf — Multi-Tenant SaaS Starter Kit

Wharf — Multi-Tenant SaaS Starter Kit

Live
June 2026 — July 2026Full-Stack Engineer & Architect
Next.js 15React 19TypeScriptTailwind CSSRadix UIPostgreSQLDrizzle ORMClerkZodReact Hook FormSentryVitestPlaywrightESLintPrettier
Wharf — Multi-Tenant SaaS Starter Kit project cover

Overview

Wharf is a production-oriented SaaS starter kit built on Next.js 15's App Router. It exists to solve a narrow but recurring problem: most "starter templates" ship a login page and a dashboard shell, then leave every hard architectural decision — tenancy, authorization, data access boundaries, observability — for later. Wharf makes those decisions up front, implements them completely, and documents the reasoning, so the foundation doesn't need to be re-architected once real product requirements arrive.

The result is a working application, not a wireframe: authentication, organization-scoped multi-tenancy, role-based access control, a typed database layer, Server Actions and REST endpoints backed by the same query layer, and the operational tooling (structured logging, error tracking, CI, unit + e2e tests) a real SaaS product needs on day one.

Problem

Templates that call themselves "SaaS starters" almost universally under-deliver on the parts that are actually expensive to retrofit:

  • Auth is wired in, but every resource still belongs to a single user — there's no tenant boundary, so adding "teams" later means touching every table and every query.
  • Authorization, when it exists at all, is if (user.role === 'admin') scattered across components and API routes, with no single source of truth.
  • Server Actions and API routes duplicate the same business logic independently, so they drift.
  • Testing, error tracking, and CI are either missing or added as an afterthought, meaning the first real feature built on the template also has to build the scaffolding around it.

Wharf's problem statement was: build the boring 20% of a SaaS product correctly and completely, so that 100% of subsequent product work is additive rather than a series of migrations.

Goals

  1. Model multi-tenancy as a first-class database concept, not a later retrofit.
  2. Centralize authorization in one place, enforced identically across UI, Server Actions, and REST endpoints.
  3. Give Server Actions and Route Handlers a shared data-access layer so business logic exists exactly once.
  4. Fail fast and loudly on misconfiguration (missing env vars, invalid auth state) instead of failing quietly in production.
  5. Ship with real automated tests and CI from the first commit, not as a "todo."
  6. Keep the UI layer composable and ownable — no black-box component library, no vendor lock-in on styling primitives.

Key Features

  • Clerk-backed authentication with protected route middleware and an explicit public-route allow-list
  • Organization-based multi-tenancy with an onboarding flow that creates a user's first organization
  • Role-based access control (owner / admin / member) enforced through a single permission matrix
  • Organization switching persisted via an HTTP-only cookie, resolved server-side on every request
  • Project management (create, archive, restore) as the reference domain entity, meant to be replaced or extended
  • An append-only activity log recording mutating actions per organization
  • A verified Clerk webhook handler keeping local membership state in sync with auth-provider events
  • Dual data-mutation surfaces — Server Actions for the dashboard UI, REST Route Handlers for external integrations — both built on one query layer
  • Typed, validated environment variables that halt the boot process on misconfiguration
  • Dark mode, a component library built from Radix primitives, and a toast notification system
  • Sentry error tracking wired into both the client and server runtimes
  • Vitest unit tests and Playwright end-to-end tests, both run in CI on every push

Technology Stack

Framework & Language

  • Next.js 15 (App Router, Server Actions, Route Handlers)
  • React 19
  • TypeScript (strict mode, noUncheckedIndexedAccess enabled)

Data Layer

  • PostgreSQL
  • Drizzle ORM (schema-first, SQL-generated migrations)
  • postgres (postgres.js driver) with a pooled, module-level client

Auth

  • Clerk (@clerk/nextjs) for identity, session management, and organization membership events
  • svix for verifying Clerk webhook signatures

UI

  • Tailwind CSS with a custom HSL design-token palette
  • Radix UI primitives (Dialog, DropdownMenu, Tabs, Switch, Toast, Avatar, Separator, Label)
  • class-variance-authority for variant-based component styling
  • lucide-react icons

Validation & Forms

  • Zod (schemas shared between client forms and server mutation logic)
  • React Hook Form with @hookform/resolvers

Observability & Config

  • Sentry (@sentry/nextjs) for client/server/edge error tracking
  • @t3-oss/env-nextjs for typed, fail-fast environment variable validation

Testing & Tooling

  • Vitest + Testing Library (unit tests)
  • Playwright (end-to-end tests, dual-browser: Chromium + WebKit)
  • ESLint (flat config) + Prettier + prettier-plugin-tailwindcss
  • Husky + lint-staged (pre-commit checks)
  • GitHub Actions CI (typecheck, lint, format check, unit tests, migrations, build, e2e)

Architecture

Wharf's central architectural decision is that organizations, not users, are the unit of tenancy. Every domain resource references an organization directly; there is no code path that reaches a resource through a bare user ID.

organizations ──< memberships >── (Clerk user id)
      │
      ├──< projects
      └──< activity_logs

Clerk is treated as the source of truth for identity — who a person is, whether their session is valid. Wharf's own Postgres database is the source of truth for authorization within the product — which organizations a user belongs to and what they're allowed to do there. A Clerk webhook keeps the two in sync when membership changes originate on Clerk's side.

Authorization itself is centralized into a single permission matrix rather than distributed across call sites:

const PERMISSIONS = {
  "project:create": ["owner", "admin", "member"],
  "project:archive": ["owner", "admin"],
  "member:changeRole": ["owner"],
  "organization:delete": ["owner"],
  // ...
} as const satisfies Record<string, readonly Role[]>;

Every Server Action and Route Handler that mutates state calls assertPermission(role, permission) before doing anything; every conditionally-rendered UI control calls the non-throwing can(role, permission). Adding a new permission is one line in the matrix plus one call at the point of use.

[!NOTE] requireOrganizationAccess(slug) is the single choke point every organization-scoped operation passes through. It resolves the organization, confirms membership, and returns both — there is exactly one way to check tenant access in the entire codebase.

Data mutation has two deliberate entry points that share one underlying implementation:

  • Server Actions (src/actions/*) — called directly from dashboard forms via useTransition, no JSON round-trip.
  • Route Handlers (src/app/api/*/route.ts) — a stable REST contract for external consumers (CLIs, webhooks, future integrations).

Both validate input with the same Zod schema, call the same permission check, and write through the same Drizzle queries in src/lib/db/queries/. This means the two surfaces cannot drift into inconsistent behavior over time.

Project Structure

src/
├── actions/                 # Server Actions (organization, project, profile, onboarding)
├── app/
│   ├── (marketing)/          # Public landing + pricing pages
│   ├── (auth)/                # Clerk sign-in / sign-up
│   ├── (app)/                  # Authenticated shell: dashboard, settings, projects
│   ├── onboarding/             # First-organization creation flow
│   ├── api/                    # REST endpoints, Clerk webhook, health check
│   ├── layout.tsx
│   ├── global-error.tsx
│   └── middleware.ts
├── components/
│   ├── ui/                     # Radix-based primitives (button, dialog, tabs, ...)
│   ├── layout/                 # Sidebar, navbar, org switcher, theme toggle
│   ├── dashboard/               # Stat cards, project list
│   ├── forms/                    # Profile, organization, members forms
│   └── marketing/                # Landing page sections
├── lib/
│   ├── auth/                    # current-user, permissions, errors, active-org resolution
│   ├── db/                       # Drizzle client, schema, migrate/seed scripts, queries/
│   ├── validations/            # Zod schemas shared by forms and actions
│   ├── env.ts                   # Typed environment validation
│   └── logger.ts                # Structured logging → Sentry
└── types/                     # Shared TypeScript types

tests/
├── unit/                       # Vitest: permissions, utilities
└── e2e/                          # Playwright: marketing flow, auth redirects

drizzle/                       # Generated SQL migrations + snapshots
.github/workflows/ci.yml     # Typecheck → lint → format → migrate → test → build → e2e

Implementation

Schema-first data modeling

The entire data model lives in one file, src/lib/db/schema.ts, using Drizzle's pgTable builder:

export const memberships = pgTable(
  "memberships",
  {
    id: uuid("id").primaryKey().defaultRandom(),
    organizationId: uuid("organization_id")
      .notNull()
      .references(() => organizations.id, { onDelete: "cascade" }),
    userId: text("user_id").notNull(),
    role: roleEnum("role").notNull().default("member"),
    createdAt: timestamp("created_at", { withTimezone: true })
      .notNull()
      .defaultNow(),
  },
  (table) => ({
    uniqueMember: uniqueIndex("memberships_org_user_idx").on(
      table.organizationId,
      table.userId,
    ),
  }),
);

A unique index on (organization_id, user_id) makes duplicate memberships a database-level impossibility, not just an application-level convention.

Active organization resolution

Because a user can belong to multiple organizations, Wharf resolves the "active" one per-request from an HTTP-only cookie, falling back to the user's first membership and redirecting to onboarding if they have none:

export async function getActiveOrganizationSlug(
  userId: string,
): Promise<string> {
  const memberships = await getOrganizationsForUser(userId);
  if (memberships.length === 0) redirect("/onboarding");

  const cookieStore = await cookies();
  const cookieSlug = cookieStore.get(ACTIVE_ORG_COOKIE)?.value;
  const match = memberships.find((m) => m.organization.slug === cookieSlug);

  return match?.organization.slug ?? memberships[0]!.organization.slug;
}

Switching organizations is itself a Server Action that sets the cookie and redirects — no client-side state management required.

Verified webhook handling

The Clerk webhook handler verifies the svix signature before touching the database, rejecting anything that fails verification with a 400 rather than trusting the payload:

const webhook = new Webhook(env.CLERK_WEBHOOK_SIGNING_SECRET);
const event = webhook.verify(payload, {
  "svix-id": svixId,
  "svix-timestamp": svixTimestamp,
  "svix-signature": svixSignature,
});

Fail-fast configuration

@t3-oss/env-nextjs validates every server and client environment variable against a Zod schema at process start, so a missing DATABASE_URL or malformed Clerk key surfaces immediately as a readable startup error instead of an obscure runtime failure three requests later.

Technical Challenges

Standalone scripts vs. Next.js's implicit env loading. Next.js automatically loads .env.local for the dev/build/start commands, but standalone scripts run via tsx (migrations, seeding) do not. The initial implementation loaded dotenv via a static import, which failed silently — ES module imports are hoisted above top-level code, so the typed env module evaluated (and threw) before dotenv.config() ever ran. The fix was restructuring both scripts to load dotenv first, then pull in the database client and env module via dynamic import() inside an async function, guaranteeing execution order.

Testing server-only code. ForbiddenError/UnauthorizedError initially lived alongside Clerk-dependent helpers in a module marked with the server-only import guard. Vitest, which doesn't understand Next.js's RSC boundary, failed to import that module at all. The fix was extracting the error classes into a dependency-free errors.ts module, decoupling pure, testable logic from server-only runtime code — a pattern now applied throughout lib/auth.

typedRoutes vs. dynamically constructed hrefs. Next.js's experimental typed-routes feature conflicts with Link hrefs built from runtime data (e.g., `/dashboard?org=${slug}`), which the organization switcher and several other components rely on. Rather than force every dynamic link through a workaround, the feature was disabled — a deliberate tradeoff of compile-time route safety for simpler, more flexible navigation code.

Performance Optimizations

  • A single module-level postgres() client is memoized on globalThis in development to survive Next.js's hot-module-reload cycles without exhausting connection limits; pool size is explicitly capped (10 in production, 5 in development) to stay serverless-friendly.
  • Server Components fetch data directly (getProjectsForOrganization, getMembersForOrganization) with no client-side data-fetching waterfall on initial dashboard load.
  • revalidatePath is scoped narrowly to the affected route after each mutation, avoiding unnecessary full-cache invalidation.
  • Marketing pages are fully static-generated at build time; only the authenticated (app) route group renders dynamically.

Accessibility

  • All interactive primitives (Dialog, DropdownMenu, Tabs, Switch, Toast) are built on Radix UI, which provides correct ARIA roles, keyboard navigation, and focus management out of the box rather than reimplemented from scratch.
  • Form fields use associated <Label> elements (@radix-ui/react-label) rather than placeholder-only labeling.
  • Color tokens are defined with sufficient contrast in both light and dark themes via CSS custom properties, checked against the Tailwind-generated palette.
  • Focus rings (focus-visible:ring-2) are preserved across all custom-styled interactive elements rather than suppressed.

Security

  • Every mutation path — Server Action and Route Handler alike — re-derives the caller's organization membership and role server-side; no client-supplied role or organization ID is ever trusted directly.
  • middleware.ts defaults to protecting all routes, with an explicit allow-list for public pages, so a new route is secure by default rather than accidentally exposed.
  • Clerk webhook payloads are cryptographically verified via svix before any database write occurs.
  • Response headers (X-Frame-Options, X-Content-Type-Options, Referrer-Policy) are set globally in next.config.ts.
  • Secrets are never committed; .env.example documents every required variable without real values, and CI runs against placeholder credentials with SKIP_ENV_VALIDATION.

Deployment

Wharf is designed for Vercel deployment with any managed Postgres provider (Neon, Supabase, RDS):

  1. Provision Postgres and copy the connection string.
  2. Import the repository into Vercel — vercel.json pins the framework and build/install commands explicitly.
  3. Configure environment variables matching .env.example.
  4. Run DATABASE_URL="<production-url>" npm run db:migrate explicitly — migrations are never run automatically on deploy, to avoid an unreviewed schema change silently hitting production data.
  5. Point the Clerk webhook and allowed domains at the production URL.
  6. Verify via GET /api/health.

[!WARNING] Migrations are intentionally decoupled from the deploy pipeline. Auto-running db:migrate on every deploy trades a small convenience for the risk of an unreviewed destructive migration running against live data.

Screenshots

Dashboard Organization Settings Landing Page

Metrics

MetricResult
Production build16 routes, zero build errors
Type checkingtsc --noEmit — 0 errors (strict mode)
Linteslint --max-warnings 0 — 0 errors, 0 warnings
Unit tests15 passing (permission matrix + utilities)
Migration verificationApplied cleanly against a live PostgreSQL 16 instance
CI pipeline stagestypecheck → lint → format → migrate → test → build → e2e

Lessons Learned

  1. Centralizing authorization pays for itself immediately. Writing one permission matrix took longer up front than scattering if (role === 'admin') checks, but every subsequent feature (archiving, member removal, role changes) required zero new authorization design — just a new matrix entry.
  2. Static import hoisting is a real footgun for CLI scripts. Any Node script that needs dotenv loaded before other modules evaluate must either use dynamic imports or a -r preload flag — a static import ordering assumption will silently break.
  3. Decoupling pure logic from framework boundaries (server-only) early makes testing dramatically simpler. Error classes and permission logic that don't depend on Clerk or Next.js internals should live in their own modules from the start, not be extracted under test pressure later.
  4. Two mutation surfaces (Server Actions + REST) are worth the extra file only if they share a data layer. Building them independently would have doubled the maintenance surface for no benefit.

Future Roadmap

  • Organization-scoped multi-tenancy with unique membership constraints
  • Centralized RBAC permission matrix
  • Clerk webhook synchronization for membership removal
  • Server Action + REST dual mutation surface backed by one query layer
  • CI pipeline with typecheck, lint, unit tests, migrations, and build
  • Stripe/Paddle billing integration keyed on organization_id
  • Transactional email delivery for member invitations
  • Authenticated Playwright coverage using Clerk testing tokens
  • Rate limiting on public Route Handlers (Upstash-based)
  • Internationalization via a [locale] route segment

Conclusion

Wharf treats the unglamorous 20% of a SaaS product — tenancy, authorization, data-access consistency, and operational tooling — as the actual deliverable, on the premise that getting these right once makes every subsequent feature additive rather than a migration. The architecture is intentionally boring: one schema file, one permission matrix, one query layer shared by two mutation surfaces. That boredom is the point — it's the part of the codebase future engineers should never need to redesign.