> ## Documentation Index
> Fetch the complete documentation index at: https://vlyai-1c28d863.mintlify.site/llms.txt
> Use this file to discover all available pages before exploring further.

# Multi-tenant SaaS

> The foundation pattern for every B2B app. Workspaces, teams, members, roles, and per-tenant data isolation. Take this and add your domain logic on top.

export const PromptBlock = ({children, variant}) => <div className={`vly-prompt ${variant || ""}`}>{children}</div>;

export const Prerequisites = ({items}) => <Note>
    <strong>Before you start.</strong>
    <ul>
      {(items || ["A vly.ai account (free tier is enough)", "A modern browser (Chrome, Firefox, Safari, Edge — all current versions)", "About 10 minutes"]).map((item, i) => <li key={i}>{item}</li>)}
    </ul>
  </Note>;

This recipe builds the **foundation pattern for B2B SaaS apps** — workspaces, teams, members, roles, billing, and per-tenant data isolation. The exact features you ship on top will vary; this base is the same for everyone.

By the end you'll have:

* Sign-up flow that creates a workspace.
* Invite-based team membership with roles (owner / admin / member).
* Per-workspace data isolation enforced at the schema level.
* A workspace switcher in the UI.
* The shape ready to attach billing, projects, or any domain logic.

**Time:** \~45 minutes. **Difficulty:** Intermediate.

## Prerequisites

<Prerequisites
  items={[
"A vly project (start a new one for this recipe)",
"About 45 minutes",
"Familiarity with [the five-layer prompting method](/prompting/the-five-layer-method)"
]}
/>

## What you'll build

```mermaid theme={null}
graph TB
    U1[User Alice]
    U2[User Bob]
    U3[User Carol]
    W1[Workspace Acme]
    W2[Workspace BetaCorp]
    M1[Member: Alice / owner]
    M2[Member: Bob / admin]
    M3[Member: Carol / member]
    M4[Member: Carol / owner]
    P1[Acme data: projects, tasks, etc]
    P2[BetaCorp data: projects, tasks, etc]

    U1 --> M1
    U2 --> M2
    U3 --> M3
    U3 --> M4
    M1 --> W1
    M2 --> W1
    M3 --> W1
    M4 --> W2
    W1 --> P1
    W2 --> P2

    style W1 fill:#EDE9FE,stroke:#6D28D9
    style W2 fill:#CFFAFE,stroke:#06B6D4
    style P1 fill:#EDE9FE,stroke:#6D28D9
    style P2 fill:#CFFAFE,stroke:#06B6D4
```

Key invariants:

* A user can belong to many workspaces (Carol is in two).
* Within a workspace, a user has exactly one role.
* All domain data is scoped to a workspace.

## Step 1 — The foundation prompt

Start a new vly project. Use **Plan mode**.

<PromptBlock>
  **Goal**: a multi-tenant SaaS foundation. Users sign up, create or join workspaces, invite teammates, and have role-based access to workspace data.

  **User**:

  * Anyone can sign up (email + password, plus Google OAuth).
  * After sign-up, the user creates a workspace OR joins one via invite link.
  * Within a workspace, users have one of three roles: owner, admin, or member.

  **Data**:

  * `users` (built-in auth)
  * `workspaces`: `{ name, slug, ownerId, createdAt }`. Slug is unique, URL-safe.
  * `memberships`: `{ workspaceId, userId, role }`. Composite uniqueness on (workspaceId, userId). Index by\_workspace, by\_user, by\_workspace\_and\_user.
  * `invites`: `{ workspaceId, email, role, token, expiresAt, acceptedAt? }`. Token is unguessable. Index by\_token.

  **Behavior**:

  * Onboarding flow:
    * New user lands on /onboarding
    * Two cards: "Create a workspace" or "Join a workspace"
    * Create: form for name and slug; user becomes owner of the new workspace
    * Join: paste invite link or token; if valid + matching email, accept and become a member
  * Workspace switcher in the header — dropdown of all workspaces the user belongs to
  * Each workspace has a settings page (admins only) with members list and invite UI
  * Sending an invite generates a token, creates an `invites` row, sends an email via Resend
  * Active workspace stored in URL: every authenticated route is `/w/{workspaceSlug}/...`

  **Constraints**:

  * All data queries automatically scope to the active workspace (no leakage)
  * Owners cannot be removed by admins; only by other owners
  * Last owner cannot leave or be removed
  * Invite tokens expire after 7 days
  * Slugs are immutable after creation
</PromptBlock>

Submit. Review the plan. Plan mode should produce a schema with the four tables plus indexes, the routes for `/onboarding` and `/w/[slug]/...`, the auth middleware that scopes queries, and the workspace switcher component.

Common tweaks at this stage:

* "Slug max length is 32 chars."
* "Invite link includes the email it was sent to as a query param so the join page can pre-fill."
* "When the last owner is removed, transfer ownership to the longest-tenured admin instead of blocking."

Approve and build.

## Step 2 — Verify the basics

Once the build completes:

<Steps>
  <Step title="Sign up as user 1">
    Email + password works. Land on /onboarding.
  </Step>

  <Step title="Create a workspace">
    Name "Acme", slug "acme". You become the owner. URL becomes `/w/acme/...`.
  </Step>

  <Step title="Open settings, invite a teammate">
    Use a second email address (or use Resend test-mode and check the Resend dashboard for the email). Set role "admin".
  </Step>

  <Step title="Open the invite link in incognito">
    Sign up as the second user. The invite is auto-accepted; you're redirected to `/w/acme/...` with admin permissions.
  </Step>

  <Step title="Verify isolation">
    Have user 2 create a second workspace ("BetaCorp"). Switch back to user 1's view — only Acme should be visible.
  </Step>
</Steps>

<Check>
  Sign-up, workspace creation, invites, role assignments, and tenant isolation all work. This is the foundation.
</Check>

## Step 3 — Add domain data

Now layer your actual product. The pattern: every domain table has a `workspaceId`, every query filters on the active workspace.

Example — adding "Projects" as your first domain entity:

<PromptBlock>
  **Goal**: each workspace has projects. Members can create projects; admins can edit and delete them.

  **Data**:

  * `projects`: `{ workspaceId, name, description, createdBy, createdAt }`
  * Index by\_workspace and by\_workspace\_and\_creator

  **Behavior**:

  * New route `/w/{slug}/projects` lists all projects in the active workspace
  * "New project" button (any role) opens a modal
  * Each project has a detail page `/w/{slug}/projects/{projectId}`
  * "Delete project" only visible to admins and owners

  **Constraints**:

  * All queries filter by the active workspace's id
  * Creating a project sets `createdBy` to the current user
  * Project deletion cascades to all child entities (which you'll add later)
</PromptBlock>

Now repeat for tasks, comments, files, or whatever your app needs. The base pattern stays the same:

* Add `workspaceId` to the schema.
* Index by\_workspace.
* Filter by active workspace in every query.

vly handles the per-feature wiring; the architectural pattern is solid because it was built into the foundation.

## Step 4 — Add billing (optional)

If you're charging customers, the standard add-on:

<PromptBlock>
  **Goal**: each workspace has a subscription. Three tiers: Free / Pro $10/mo / Business $40/mo.

  **User**: Only owners can manage billing.

  **Data**:

  * Add `subscription` field to `workspaces`: `{ stripeCustomerId, stripeSubscriptionId, planTier, status, currentPeriodEnd }`

  **Behavior**:

  * Settings → Billing (owners only) shows current plan and three plan cards
  * Upgrade goes to Stripe Checkout
  * "Manage billing" opens Stripe customer portal
  * Workspace marked "past\_due" if subscription invoice fails; banner across the workspace

  **Constraints**:

  * Must use Stripe (use the existing Stripe integration if connected, otherwise prompt to set it up)
  * Free tier limits enforced at the data layer (e.g., max 3 projects on Free)
</PromptBlock>

vly will set up the Stripe integration if needed. See [Stripe](/integrations/stripe) for setup.

## Step 5 — SSO (enterprise tier, optional)

If you'll have B2B customers with strict auth requirements, SSO via SAML / OIDC:

<PromptBlock>
  **Goal**: enterprise workspaces support SSO via SAML. Users from configured email domains are auto-routed to their identity provider.

  **User**: SSO is configured by the workspace owner (enterprise plan only).

  **Data**:

  * Add `ssoConfig` to `workspaces`: `{ provider, metadataUrl, domains }` (optional, only for enterprise tier)

  **Behavior**:

  * New page Settings → SSO (enterprise + owner only) for paste-in-metadata flow
  * Sign-in page detects email domain — if it matches a workspace's `ssoConfig.domains`, redirects to that provider
  * Use the WorkOS integration for SAML/OIDC plumbing

  **Constraints**:

  * Domains must be verified (vly will set up the verification flow)
  * Falls back to email/password if SSO fails
</PromptBlock>

See [WorkOS](/integrations/workos) for the underlying integration.

## Going further

Layer any of these on the foundation:

<CardGroup cols={2}>
  <Card title="Audit logs" icon="scroll-text" href="/features/workspace/audit-logs">
    Every meaningful action gets an audit log entry, scoped to the workspace.
  </Card>

  <Card title="Webhooks for events" icon="webhook" href="/recipes/webhooks">
    Customers can register webhook endpoints to react to workspace events.
  </Card>

  <Card title="API tokens for customers" icon="key" href="/api-reference/authentication">
    Let customers integrate your app via API. Per-workspace tokens, rate-limited.
  </Card>

  <Card title="Multi-region" icon="globe">
    For latency-sensitive customers, deploy data closer. (Currently roadmap.)
  </Card>
</CardGroup>

## Common mistakes

<AccordionGroup>
  <Accordion title="Forgetting to scope a query" icon="alert-triangle">
    Any query that returns workspace data must filter by the active workspace. vly handles this when the agent generates code, but if you hand-write a query in [Code mode](/building/code-mode), don't forget the filter — that's how data leaks happen.
  </Accordion>

  <Accordion title="Not handling the 'last owner' case" icon="alert-triangle">
    "Remove member" needs to refuse if the target is the only owner. vly handles this in the prompt above; if you skip it, you can lock yourself out of your own workspace.
  </Accordion>

  <Accordion title="Storing plan limits in code instead of DB" icon="alert-triangle">
    "Free tier max 3 projects" — store the limits in a `plans` table, not as `if (plan === 'free') ...` scattered through code. Easier to change.
  </Accordion>
</AccordionGroup>

## Related

<CardGroup cols={3}>
  <Card title="Auth flows" icon="key" href="/architecture/auth-flows">
    Patterns for the auth scenarios common in multi-tenant SaaS.
  </Card>

  <Card title="Multi-tenancy patterns" icon="building" href="/architecture/multi-tenancy">
    Deeper architectural reading.
  </Card>

  <Card title="Subscription billing recipe" icon="repeat" href="/recipes/subscription-billing">
    Full billing setup.
  </Card>
</CardGroup>


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.