Build This Now
Build This Now
クロード・コードとは何か?Claude Code のインストールClaude Code ネイティブインストーラーClaude Code で最初のプロジェクトを作る
Subscription BillingMulti-Tenant SaaSAI Chat FeatureRate LimitingStripe WebhooksSemantic SearchRealtime UpdatesDrip Email SequencesClaude Code v2.1.122 Release NotesClaude Code の Dynamic Workflows:実際のコードベースで 1,000 個の subagents を動かす方法Claude Code ベストプラクティスClaude Opus 4.7 ベストプラクティスVPS上でのClaude CodeGit 統合Claude Code レビューClaude Code WorktreesClaude CodeリモートコントロールClaude Code ChannelsChannels、Routines、Teleport、DispatchClaude Code スケジュールタスクClaude Code権限管理Claude Code オートモードClaude Code で Stripe 決済を組み込むフィードバックループTodoワークフローClaude Code タスク管理プロジェクトテンプレートClaude Code の料金とトークン使用量Claude Code の料金:実際にいくら払うことになるのかClaude Code Ultra Review 完全ガイドClaude Code で Next.js アプリを作るClaude Code With Supabase: Database, Auth, RLSVercel deepsec with Claude CodeTest-Driven Development with Claude CodeClaude Code で SaaS の MVP を作る方法Claude Code で認証を追加する(Supabase Auth)Claude Code でトランザクションメールを追加する(Resend + React Email)Claude Code で型安全な API を作る(oRPC + Zod)How to Add File Uploads With Claude Code (Supabase Storage)How to Run Background Jobs with Claude Code and InngestHow to Build an Admin Dashboard With Claude CodeHow to Add Full-Text Search With Claude Code (Postgres)エージェント型コマース:AI エージェントが支払えるアプリの作り方Claude Code 1M Context in Practice: When Bigger Isn't BetterClaude Code GitHub Actions Setup Guide (@claude + Cron)Headless ModeMax Plan vs APIPrompt Caching2026年、Claude Code で SaaS を作るといくらかかるかRun a Team of AI Agents in Parallel with Git WorktreesPrompt Injection in Coding Agents: How to Not Get Pwned
speedy_devvkoen_salo
Blog/Handbook/Workflow/Multi-Tenant SaaS

Multi-Tenant Workspaces with Claude Code

Add organizations, seats, and invite flows to a Next.js SaaS: workspace-scoped tables, Supabase RLS for tenant isolation, team invites via Resend, and a member and role switcher.

設定をやめて、構築を始めよう。

AIオーケストレーション付きSaaSビルダーテンプレート。

企業向けに構築している実績を見る →
speedy_devvWritten by speedy_devvPublished Jul 14, 202611 min readHandbook hubWorkflow index

Every B2B SaaS eventually needs the same backbone: organizations, seats, and invites. One user signs up, creates a workspace, and invites their team. Get the isolation wrong and one customer sees another's data, which is the single fastest way to lose a B2B account. This guide builds the whole thing on Next.js 16 and Supabase, with Claude Code doing the heavy lifting and Postgres Row Level Security guaranteeing tenants never bleed into each other.


設定をやめて、構築を始めよう。

AIオーケストレーション付きSaaSビルダーテンプレート。

企業向けに構築している実績を見る →

The Data Model: Workspaces, Memberships, Invites

Three tables carry the entire multi-tenant model. A workspaces table is the tenant. A memberships table joins users to workspaces and carries their role. An invites table holds pending email invitations until someone accepts.

The key decision is that membership is its own table, not a column on the user. A user can belong to many workspaces, and each membership has its own role. That is what makes seats, invites, and the role switcher possible later.

Ask Claude Code to plan the migration before it writes any SQL:

claude --plan "add multi-tenant workspaces: workspaces, memberships (user to workspace with a role), and email invites. Every tenant table gets a workspace_id. Plan the schema and the RLS policies."

Review the plan, then let it write the migration. Here is the schema it should produce. Roles are constrained to a small set so a typo never becomes a permission.

-- supabase/migrations/0001_workspaces.sql

create table workspaces (
  id uuid primary key default gen_random_uuid(),
  name text not null,
  slug text not null unique,
  created_at timestamptz not null default now()
);

create type member_role as enum ('owner', 'admin', 'member');

create table memberships (
  id uuid primary key default gen_random_uuid(),
  workspace_id uuid not null references workspaces(id) on delete cascade,
  user_id uuid not null references auth.users(id) on delete cascade,
  role member_role not null default 'member',
  created_at timestamptz not null default now(),
  unique (workspace_id, user_id)
);

create table invites (
  id uuid primary key default gen_random_uuid(),
  workspace_id uuid not null references workspaces(id) on delete cascade,
  email text not null,
  role member_role not null default 'member',
  token text not null unique,
  invited_by uuid not null references auth.users(id),
  accepted_at timestamptz,
  created_at timestamptz not null default now(),
  unique (workspace_id, email)
);

create index memberships_user_idx on memberships (user_id);
create index memberships_workspace_idx on memberships (workspace_id);
create index invites_token_idx on invites (token);

The unique (workspace_id, user_id) constraint means a user cannot be added to the same workspace twice. The unique (workspace_id, email) on invites stops duplicate pending invites for the same person.

Scoping Every Table to a Workspace

Every table that holds tenant data needs a workspace_id column. This is the column RLS keys off of, and the column your queries filter by. Forgetting it on one table is how a leak happens.

Say your product has a projects table. It gets a workspace_id foreign key like everything else:

-- supabase/migrations/0002_projects.sql

create table projects (
  id uuid primary key default gen_random_uuid(),
  workspace_id uuid not null references workspaces(id) on delete cascade,
  name text not null,
  created_by uuid not null references auth.users(id),
  created_at timestamptz not null default now()
);

create index projects_workspace_idx on projects (workspace_id);

Add a line to your CLAUDE.md so the rule is enforced on every future table Claude generates, not just the ones you remember to mention:

## Multi-tenancy

- Every tenant-owned table MUST have a workspace_id uuid column
  referencing workspaces(id) on delete cascade.
- Every such table MUST have RLS enabled with a policy that checks
  membership via is_workspace_member(workspace_id).
- Never query a tenant table without a workspace_id filter.

That one block turns a rule you have to police into a rule Claude applies by default. It reads CLAUDE.md before writing code, so new tables arrive already scoped.

RLS: Tenant Isolation That Survives a Bad Query

Application code will eventually forget a WHERE workspace_id = ... clause. Row Level Security is the safety net that makes that mistake harmless instead of catastrophic. With RLS on, Postgres itself hides rows the current user has no membership for, regardless of what the query asks for.

The one trap: a policy on memberships that queries memberships recurses. The fix is a SECURITY DEFINER function that reads membership with the policy check bypassed, so the policy can call it safely.

-- supabase/migrations/0003_rls.sql

-- Reads membership without triggering RLS on memberships itself.
create or replace function is_workspace_member(target uuid)
returns boolean
language sql
security definer
set search_path = public
stable
as $$
  select exists (
    select 1 from memberships
    where workspace_id = target
      and user_id = auth.uid()
  );
$$;

-- Same idea, but also checks the role is elevated.
create or replace function is_workspace_admin(target uuid)
returns boolean
language sql
security definer
set search_path = public
stable
as $$
  select exists (
    select 1 from memberships
    where workspace_id = target
      and user_id = auth.uid()
      and role in ('owner', 'admin')
  );
$$;

Now enable RLS and write policies. Members can read their workspaces and everything scoped to them. Only admins and owners can change workspace settings or manage members.

-- workspaces: members read, admins update
alter table workspaces enable row level security;

create policy "members read workspaces" on workspaces
  for select using (is_workspace_member(id));

create policy "admins update workspaces" on workspaces
  for update using (is_workspace_admin(id));

-- memberships: members see their workspace's roster, admins manage it
alter table memberships enable row level security;

create policy "members read roster" on memberships
  for select using (is_workspace_member(workspace_id));

create policy "admins manage members" on memberships
  for all using (is_workspace_admin(workspace_id))
  with check (is_workspace_admin(workspace_id));

-- projects: any member can read and write within their workspace
alter table projects enable row level security;

create policy "members access projects" on projects
  for all using (is_workspace_member(workspace_id))
  with check (is_workspace_member(workspace_id));

The using clause filters which existing rows are visible or changeable. The with check clause validates rows being inserted or updated, which stops a member from writing a row into a workspace they do not belong to. Both matter. A policy with using but no with check on writes lets a user create rows in someone else's tenant.

Resolving the Active Workspace on the Server

The workspace slug lives in the URL, for example /w/acme/projects. That makes every link shareable and gives you a natural place to resolve the tenant. On each request you confirm the current user is actually a member of that workspace before rendering anything.

A single server helper does the resolution and the authorization check together. It returns the workspace and the caller's role, or redirects if they are not a member.

// lib/workspace.ts
import { redirect } from "next/navigation";
import { createServerClient } from "@/lib/supabase/server";

export type MemberRole = "owner" | "admin" | "member";

export async function requireWorkspace(slug: string) {
  const supabase = await createServerClient();

  const { data: { user } } = await supabase.auth.getUser();
  if (!user) redirect("/login");

  const { data: workspace } = await supabase
    .from("workspaces")
    .select("id, name, slug")
    .eq("slug", slug)
    .single();

  if (!workspace) redirect("/");

  const { data: membership } = await supabase
    .from("memberships")
    .select("role")
    .eq("workspace_id", workspace.id)
    .eq("user_id", user.id)
    .single();

  if (!membership) redirect("/");

  return {
    workspace,
    role: membership.role as MemberRole,
    userId: user.id,
  };
}

Because RLS is on, the memberships query only returns a row when the user genuinely belongs to that workspace. The check is enforced twice: once in this helper for a clean redirect, and once in the database as the real boundary.

A workspace page in Next.js 16 awaits its params and passes the slug straight in:

// app/w/[slug]/projects/page.tsx
import { requireWorkspace } from "@/lib/workspace";
import { createServerClient } from "@/lib/supabase/server";

interface PageProps {
  params: Promise<{ slug: string }>;
}

export default async function ProjectsPage({ params }: PageProps) {
  const { slug } = await params;
  const { workspace, role } = await requireWorkspace(slug);

  const supabase = await createServerClient();
  const { data: projects } = await supabase
    .from("projects")
    .select("id, name, created_at")
    .eq("workspace_id", workspace.id)
    .order("created_at", { ascending: false });

  return (
    <main className="max-w-3xl mx-auto py-10">
      <h1 className="text-2xl font-bold">{workspace.name}</h1>
      <p className="text-sm text-muted-foreground">Your role: {role}</p>
      <ul className="mt-6 space-y-2">
        {projects?.map((p) => (
          <li key={p.id} className="rounded border p-3">{p.name}</li>
        ))}
      </ul>
    </main>
  );
}

For a bare visit to the app root, mirror the last-used slug into a cookie and redirect. Keep this in proxy.ts (Next.js 16 renamed middleware.ts), reading the cookie and sending the user to their workspace:

// proxy.ts
import { NextRequest, NextResponse } from "next/server";

export function proxy(request: NextRequest) {
  if (request.nextUrl.pathname === "/") {
    const lastSlug = request.cookies.get("last_workspace")?.value;
    if (lastSlug) {
      return NextResponse.redirect(
        new URL(`/w/${lastSlug}/projects`, request.url),
      );
    }
  }
  return NextResponse.next();
}

export const config = { matcher: ["/"] };

The cookie is a convenience hint, never an authorization signal. The real check always happens in requireWorkspace against the database.

Inviting Teammates by Email

An invite is a row plus an email. Create the row with a random token, then send a link that carries the token. Only admins and owners can invite, which the RLS policy on invites will enforce, but check it in the action too for a friendly error.

Wrap the logic in a Server Action. It validates input with Zod, writes the invite, and sends the email through Resend. The Resend SDK returns a { data, error } shape and does not throw, so check error explicitly.

// app/w/[slug]/members/actions.ts
"use server";

import { z } from "zod";
import { randomBytes } from "crypto";
import { Resend } from "resend";
import { requireWorkspace } from "@/lib/workspace";
import { createServerClient } from "@/lib/supabase/server";

const resend = new Resend(process.env.RESEND_API_KEY);

const InviteSchema = z.object({
  slug: z.string().min(1),
  email: z.string().email(),
  role: z.enum(["admin", "member"]),
});

export async function inviteMember(input: z.infer<typeof InviteSchema>) {
  const { slug, email, role } = InviteSchema.parse(input);
  const { workspace, role: myRole, userId } = await requireWorkspace(slug);

  if (myRole !== "owner" && myRole !== "admin") {
    return { error: "Only admins can invite members." };
  }

  const token = randomBytes(24).toString("hex");
  const supabase = await createServerClient();

  const { error: dbError } = await supabase.from("invites").insert({
    workspace_id: workspace.id,
    email,
    role,
    token,
    invited_by: userId,
  });

  if (dbError) {
    return { error: "That person may already be invited." };
  }

  const link = `${process.env.NEXT_PUBLIC_APP_URL}/invite/${token}`;

  const { error: sendError } = await resend.emails.send({
    from: "Your App <team@yourapp.com>",
    to: email,
    subject: `You have been invited to ${workspace.name}`,
    html: `
      <p>You have been invited to join <strong>${workspace.name}</strong>.</p>
      <p><a href="${link}">Accept the invitation</a></p>
    `,
  });

  if (sendError) {
    return { error: "Invite saved but the email failed to send." };
  }

  return { ok: true };
}

Notice the two failure modes are handled separately. A duplicate invite fails at the database. A mail delivery problem fails at Resend. Returning distinct messages saves you a confused support ticket later.

Accepting an Invite

The invite link lands on a route that looks up the token, confirms the signed-in user's email matches, and creates the membership. The token is the only secret, so a wrong or already-used token gets a clean rejection.

// app/invite/[token]/page.tsx
import { redirect } from "next/navigation";
import { createServerClient } from "@/lib/supabase/server";

interface PageProps {
  params: Promise<{ token: string }>;
}

export default async function AcceptInvitePage({ params }: PageProps) {
  const { token } = await params;
  const supabase = await createServerClient();

  const { data: { user } } = await supabase.auth.getUser();
  if (!user) redirect(`/login?next=/invite/${token}`);

  const { data: invite } = await supabase
    .from("invites")
    .select("id, workspace_id, email, role, accepted_at")
    .eq("token", token)
    .single();

  if (!invite || invite.accepted_at) {
    return <p className="p-10">This invitation is no longer valid.</p>;
  }

  if (invite.email.toLowerCase() !== user.email?.toLowerCase()) {
    return <p className="p-10">This invite was sent to a different email.</p>;
  }

  await supabase.from("memberships").insert({
    workspace_id: invite.workspace_id,
    user_id: user.id,
    role: invite.role,
  });

  await supabase
    .from("invites")
    .update({ accepted_at: new Date().toISOString() })
    .eq("id", invite.id);

  const { data: workspace } = await supabase
    .from("workspaces")
    .select("slug")
    .eq("id", invite.workspace_id)
    .single();

  redirect(`/w/${workspace?.slug}/projects`);
}

Matching the invite email to the authenticated user's email closes an obvious hole: without it, anyone with the link could join. Marking accepted_at makes the token single-use.

For the membership insert to succeed under RLS, the accept flow needs a policy that lets a user create their own membership when a valid invite exists. Add it alongside the admin policy:

create policy "accept own invite" on memberships
  for insert
  with check (
    user_id = auth.uid()
    and exists (
      select 1 from invites i
      where i.workspace_id = memberships.workspace_id
        and lower(i.email) = lower(auth.jwt() ->> 'email')
        and i.accepted_at is null
    )
  );

The Member and Role Switcher

Two pieces of UI complete the picture. A members list where admins change roles or remove people, and a workspace switcher for users who belong to more than one tenant. Both are small Client Components on top of shadcn/ui.

The role switcher is a select that fires a Server Action. Only render the editable control when the current user is an admin or owner.

// app/w/[slug]/members/role-select.tsx
"use client";

import { useTransition } from "react";
import { updateRole } from "./actions";

interface Props {
  slug: string;
  memberId: string;
  currentRole: "owner" | "admin" | "member";
  editable: boolean;
}

export function RoleSelect({ slug, memberId, currentRole, editable }: Props) {
  const [pending, startTransition] = useTransition();

  if (!editable) {
    return <span className="text-sm capitalize">{currentRole}</span>;
  }

  return (
    <select
      defaultValue={currentRole}
      disabled={pending}
      onChange={(e) =>
        startTransition(() =>
          updateRole({ slug, memberId, role: e.target.value as Props["currentRole"] }),
        )
      }
      className="rounded border px-2 py-1 text-sm"
    >
      <option value="member">Member</option>
      <option value="admin">Admin</option>
      <option value="owner">Owner</option>
    </select>
  );
}

The action behind it re-checks the caller is an admin. Never trust the client to have hidden the control. A curious user can call the action directly, so authorization lives on the server.

// app/w/[slug]/members/actions.ts  (same file, add this export)
export async function updateRole(input: {
  slug: string;
  memberId: string;
  role: "owner" | "admin" | "member";
}) {
  const { workspace, role: myRole } = await requireWorkspace(input.slug);
  if (myRole !== "owner" && myRole !== "admin") {
    return { error: "Not allowed." };
  }

  const supabase = await createServerClient();
  const { error } = await supabase
    .from("memberships")
    .update({ role: input.role })
    .eq("id", input.memberId)
    .eq("workspace_id", workspace.id);

  return error ? { error: "Update failed." } : { ok: true };
}

The workspace switcher lists every workspace the user belongs to and links to each one. Because RLS scopes the query, this list is automatically correct with no manual filtering:

// components/workspace-switcher.tsx
import Link from "next/link";
import { createServerClient } from "@/lib/supabase/server";

export async function WorkspaceSwitcher({ current }: { current: string }) {
  const supabase = await createServerClient();
  const { data } = await supabase
    .from("memberships")
    .select("workspaces(slug, name)")
    .order("created_at", { ascending: true });

  const workspaces = data?.map((m) => m.workspaces).flat() ?? [];

  return (
    <nav className="flex flex-col gap-1">
      {workspaces.map((w) => (
        <Link
          key={w.slug}
          href={`/w/${w.slug}/projects`}
          className={w.slug === current ? "font-semibold" : "text-muted-foreground"}
        >
          {w.name}
        </Link>
      ))}
    </nav>
  );
}

What Claude Code Handles, and Where You Stay Careful

Claude Code is strong at the mechanical parts here: generating the migrations, wiring the Server Actions, and building the shadcn components. Where you review closely is the RLS policies. A missing with check, a policy that reads its own table without a SECURITY DEFINER helper, or a table that never got RLS enabled are the mistakes that leak data, and none of them show up as a type error.

Make Claude prove isolation before you trust it. Ask it to write a test that signs in as a user in workspace A and confirms a query for workspace B returns nothing:

claude "write a test that creates two workspaces with one user each, then confirms user A cannot read user B's projects through the Supabase client. Run it."

Then run the standard gates. Zero type errors and a clean build are the floor:

npx tsc --noEmit
npm run build

Multi-tenancy is one of those features where a passing build tells you nothing about whether the isolation holds. The test above is the check that matters.

Building this by hand is a weekend. Doing it with a single Claude Code session is faster, but you still babysit the RLS review and write the isolation test yourself. The $29 Code Kit is a harness on top of Claude Code that runs that loop for you: it plans the feature, builds it, evaluates the output, tests it against a real browser, and holds every change to the same quality gates (zero type errors, zero lint errors, a clean build) before it ships. It is a one-time $29 purchase with no subscription. Claude Code itself still needs a paid Anthropic plan to run. The Code Kit does not replace that, it just makes the pipeline around it repeatable, so tenant-isolation tests and RLS reviews happen every time instead of the times you remember.

設定をやめて、構築を始めよう。

AIオーケストレーション付きSaaSビルダーテンプレート。

企業向けに構築している実績を見る →

Posted by @speedy_devv

Continue in Workflow

  • エージェント型コマース:AI エージェントが支払えるアプリの作り方
    2026年のエージェント型コマースをわかりやすく解説するガイド。x402、ACP、Machine Payments Protocol が何をするのか、そして AI エージェントが購入できる有料 API を週末で出荷するための手順を紹介します。
  • Claude Code ベストプラクティス
    Claude Codeで成果を出すエンジニアを分ける5つの習慣: PRD、モジュラーなCLAUDE.mdのルール、カスタムスラッシュコマンド、/clearリセット、そしてシステム進化の思考法。
  • Claude Code オートモード
    2つ目の Sonnet モデルが、Claude Code のすべてのツール呼び出しを実行前に審査します。オートモードがブロックするもの・許可するもの、そして settings.json に追加される許可ルールについて解説します。
  • Channels、Routines、Teleport、Dispatch
    Anthropic が2026年3月と4月に出荷した4つの Claude Code 機能。これらは CLI を、スマホ・ウェブ・デスクトップをまたぐイベント駆動の調整レイヤーに変えます。
  • Claude Code 1M Context in Practice: When Bigger Isn't Better
    The 1M-token context window is GA at flat pricing, but bigger isn't always better. A decision framework, token-cost math, and when to use /compact, subagents, and dynamic workflows instead.
  • How to Build an Admin Dashboard With Claude Code
    Ship an internal admin panel with Claude Code: role-gated routes, a searchable users and orders table with pagination, impersonation-safe RLS, and metrics tiles pulled through a type-safe API.

More from Handbook

  • エージェントの基礎
    Claude Codeでスペシャリストエージェントを構築する5つの方法:タスクサブエージェント、.claude/agents YAML、カスタムスラッシュコマンド、CLAUDE.mdペルソナ、パースペクティブプロンプト。
  • エージェント・ハーネス・エンジニアリング
    ハーネスとは、AIエージェントを構成するモデル以外のすべての層のことです。5つの制御レバー、制約のパラドックス、そしてなぜハーネス設計がモデルよりもエージェントのパフォーマンスを左右するのかを学びましょう。
  • エージェントパターン
    オーケストレーター、ファンアウト、バリデーションチェーン、スペシャリストルーティング、プログレッシブリファインメント、ウォッチドッグ。Claude Code のサブエージェントを組み合わせる6つのオーケストレーション形状。
  • エージェントチームのベストプラクティス
    Claude Code エージェントチームの実証済みパターン。コンテキストが豊富なスポーンプロンプト、適切なサイズのタスク、ファイルオーナーシップ、デリゲートモード、v2.1.33〜v2.1.45 の修正内容。

設定をやめて、構築を始めよう。

AIオーケストレーション付きSaaSビルダーテンプレート。

企業向けに構築している実績を見る →

Subscription Billing

Ship recurring plans end to end with Claude Code: tiered pricing, Stripe Checkout for subscriptions, proration on plan changes, the customer portal, and webhook-driven entitlement sync into Supabase.

AI Chat Feature

Build a streaming in-app AI assistant with the Vercel AI SDK: token streaming to the UI, tool calling into your own data, message persistence in Supabase, and per-user usage limits.

On this page

The Data Model: Workspaces, Memberships, Invites
Scoping Every Table to a Workspace
RLS: Tenant Isolation That Survives a Bad Query
Resolving the Active Workspace on the Server
Inviting Teammates by Email
Accepting an Invite
The Member and Role Switcher
What Claude Code Handles, and Where You Stay Careful

設定をやめて、構築を始めよう。

AIオーケストレーション付きSaaSビルダーテンプレート。

企業向けに構築している実績を見る →