Skip to main content
Early access — new tools and guides added regularly
🔵 Build Real Projects — Guide 16 of 16
View track
>_ claude codeAdvanced50 min

Build a Multi-Tenant SaaS

Build a SaaS application with organization accounts, role-based access, data isolation, subscription billing, and the architectural patterns that power every B2B software product.

What you will build
A multi-tenant SaaS application with auth, org management, roles, and subscription billing

What multi-tenancy means and why it matters

Multi-tenancy means multiple customers (tenants) share the same application but their data is completely isolated. When Slack creates a new workspace, they do not spin up a new server — your workspace runs on the same infrastructure as millions of others, but you only see your data. This is the foundation of every B2B SaaS product. There are three multi-tenancy strategies: shared database with a tenant ID column on every table (simplest, what we will build), separate schemas per tenant within one database (moderate isolation), and separate databases per tenant (maximum isolation, highest complexity). The shared database approach works for most SaaS products up to significant scale. Every query includes a WHERE tenant_id = ? clause, ensuring one tenant never sees another's data. Ask Claude Code: Create a new Next.js project for a multi-tenant SaaS application — a simple project management tool where organizations can create projects, add tasks, and assign them to team members. Set up TypeScript and Tailwind CSS. Create types at src/lib/types.ts for Organization (id, name, slug, created_at), User (id, email, name, created_at), Membership (user_id, org_id, role as owner or admin or member, joined_at), Project (id, org_id, name, description, status, created_at), and Task (id, project_id, org_id, title, description, assignee_id, status as todo or in_progress or done, priority as low or medium or high, created_at). Notice that org_id appears on every entity that belongs to a tenant — this is the tenant isolation column.

Authentication and organization creation

Users authenticate individually but work within organizations. Ask Claude Code: Set up authentication using NextAuth.js. Install next-auth and configure it with a credentials provider for simplicity. Add a registration page at /auth/register with fields for name, email, and password. Add a login page at /auth/login. After registration, the user has no organization — redirect them to an onboarding flow at /onboarding that offers two options: Create a new organization or Join an existing organization with an invite code. For the Create path: show a form with organization name. When submitted, create the organization, create a membership with the owner role, and redirect to the organization dashboard. For the Join path: show an input for an invite code. Validate the code, create a membership with the member role, and redirect to that organization's dashboard. Ask Claude Code: Create the organization setup at src/app/onboarding/page.tsx. The create form should generate a URL-friendly slug from the organization name (lowercase, hyphens replacing spaces). Check that the slug is unique. After creation, redirect to /[org-slug]/dashboard. Use dynamic routing so each organization has its own URL namespace. Create a middleware at src/middleware.ts that checks authentication on all routes except /auth/* and the home page. For routes under /[org-slug]/*, verify that the authenticated user is a member of that organization. Return 404 (not 403) for non-members — revealing that an organization exists is an information leak. This middleware is the first layer of tenant isolation, ensuring users can only access organizations they belong to.

Role-based access control

Different roles have different permissions. An owner can delete the organization. An admin can manage members. A member can only work on projects and tasks. Ask Claude Code: Create a permissions system at src/lib/permissions.ts. Define permissions as granular actions: org.update, org.delete, members.invite, members.remove, members.change_role, project.create, project.update, project.delete, task.create, task.update, task.assign, and task.delete. Map each role to its permissions: owner has all permissions, admin has everything except org.delete, and member has project and task permissions only. Create a utility function hasPermission(userRole, permission) that returns a boolean. Create a React component PermissionGate that wraps UI elements and only renders them if the current user has the required permission. Ask Claude Code: Add the permission checks to API routes. Create an API route at src/app/api/[orgSlug]/projects/route.ts. The GET handler returns all projects for the organization. The POST handler creates a new project but only if the user has the project.create permission. If not, return 403 Forbidden. Add the permission check as a middleware pattern that can be reused across all API routes: async function requirePermission(req, orgSlug, permission). This function extracts the user from the session, looks up their membership and role in the organization, and checks the permission. If any step fails, it throws an appropriate error. Ask Claude Code: Add an organization settings page at /[org-slug]/settings that only owners and admins can access. Show organization name editing (owner only), member management with role changes (admin and owner), and a danger zone with organization deletion (owner only). Use the PermissionGate component to conditionally show UI elements based on the current user's role.

Data isolation and the tenant context

Every database query must be scoped to the current organization. Forgetting a WHERE org_id = ? clause is a data leak. Ask Claude Code: Create a tenant context system at src/lib/tenant.ts. This module provides a function withTenant(orgId) that returns a query builder where every query is automatically scoped to the given organization. Instead of writing db.query('SELECT * FROM projects WHERE org_id = ?', [orgId]) everywhere, you write tenant.projects.findAll() and the org_id filter is applied automatically. Implement this for projects and tasks with methods findAll, findById, create, update, and delete. Every method automatically includes the org_id condition. For creates, it automatically sets the org_id field. For updates and deletes, it adds org_id to the WHERE clause so a user in Organization A cannot modify data belonging to Organization B even if they guess the record ID. Ask Claude Code: Add a React context provider at src/contexts/TenantContext.tsx that stores the current organization ID and provides it to all child components via useOrg hook. The layout for /[org-slug]/* pages should wrap children in this provider after looking up the organization from the slug. All API calls from components should include the organization slug in the URL path, and all API handlers should resolve the slug to an ID and use the tenant-scoped query builder. Create a test that verifies isolation: create two organizations, add a project to each, and verify that querying projects from Organization A never returns Organization B's project — even if you deliberately try to access it by ID. This is the most critical test in a multi-tenant system. Ask Claude Code: Add a tenant_id audit log. Every data mutation should log the org_id, user_id, action, entity type, entity ID, and timestamp. This creates an audit trail for debugging data isolation issues.

Organization switching and invitations

Users often belong to multiple organizations. Ask Claude Code: Create an organization switcher component at src/components/OrgSwitcher.tsx that shows in the top navigation. It displays the current organization name with a dropdown listing all organizations the user belongs to. Each entry shows the organization name and the user's role. Clicking an organization navigates to its dashboard. Add a Create new organization option at the bottom of the dropdown. The switcher should load the user's memberships on mount and cache them. When the user creates or joins a new organization, update the cache. Ask Claude Code: Build an invitation system. Create an API endpoint at /api/[orgSlug]/invitations/route.ts. POST creates an invitation with the invitee's email, the role to assign, and generates a unique invite code. GET lists pending invitations. The invite code should be a random URL-safe string. Add an invitations section to the organization settings page where admins can invite new members by email and see pending invitations with the ability to revoke them. Create an accept invitation page at /invite/[code] that shows the organization name and the role offered. If the user is logged in, clicking Accept creates their membership. If not, they are prompted to register first, then redirected back to accept. Send invitation emails using Resend with the invitation link. Ask Claude Code: Add role management to the members list in organization settings. Admins and owners can change a member's role using a dropdown. Owners can transfer ownership to another member (which demotes the current owner to admin). Add a Leave organization option for non-owners. When the last member leaves, the organization is soft-deleted. Add protections: the owner cannot leave without transferring ownership, and members cannot escalate their own role.

Subscription billing with Stripe

SaaS products charge recurring subscriptions. Ask Claude Code: Integrate Stripe Subscriptions for billing. Create three plans: Free (up to 2 members, 3 projects), Pro at 29 pounds per month (up to 10 members, unlimited projects), and Team at 79 pounds per month (unlimited members, unlimited projects, priority support). Create the products and prices in Stripe using a setup script. Create a billing page at /[org-slug]/billing that shows the current plan, usage against limits, and upgrade or downgrade buttons. The upgrade flow uses Stripe Checkout in subscription mode. The downgrade flow uses the Stripe API to update the subscription to a lower-priced plan. Add a Stripe customer portal link for managing payment methods and viewing invoices. Create webhook handlers for subscription events: customer.subscription.created, customer.subscription.updated, customer.subscription.deleted, and invoice.payment_failed. When a subscription changes, update the organization's plan in your database. When a payment fails, send an email to the organization owner and show a banner in the app. Ask Claude Code: Add plan limit enforcement throughout the application. When a member is invited, check if the organization has reached its member limit for the current plan. If so, show a message prompting an upgrade. When a project is created, check the project limit. Create a utility function checkPlanLimit(orgId, resource) that returns whether the action is allowed and how close to the limit they are. Show a usage bar on the billing page: 2 of 3 projects used with a visual indicator. As usage approaches the limit, show upgrade prompts in context — not as annoying popups but as helpful inline messages like Need more projects? Upgrade to Pro for unlimited projects.

Dashboard, activity feeds, and notifications

The organization dashboard is the daily landing page for users. Ask Claude Code: Create a dashboard at /[org-slug]/dashboard with four sections. First, a summary row with cards showing total projects, total tasks, tasks completed this week, and team members online. Second, a My Tasks section showing tasks assigned to the current user sorted by priority then due date. Each task shows the project name, task title, priority badge, and status. Third, a Recent Activity feed showing the last 20 actions across the organization: member X created project Y, member X completed task Y, new member X joined, and similar events. Each entry has an icon, description, relative timestamp, and avatar of the person who performed the action. Fourth, a projects list showing all projects with their task completion percentage as a progress bar. Ask Claude Code: Build the activity tracking system. Create a function logActivity(orgId, userId, action, entityType, entityId, metadata) that records every significant action. Call this function from all create, update, and delete operations across the application. The activity feed on the dashboard reads from this log. Add filtering by project and by team member. Add an activity feed to each project page showing only that project's activity. Ask Claude Code: Add in-app notifications. When a task is assigned to a user, create a notification. When a task they created is completed, create a notification. Show a bell icon in the navigation with an unread count badge. Clicking it opens a notifications panel with each notification showing what happened, who did it, and when. Mark notifications as read when the panel is opened. Add a link on each notification to navigate to the relevant page. Store notifications per user and scope them to the current organization.

Testing, security audit, and deployment

Multi-tenant applications require rigorous testing because data leaks affect real customers. Ask Claude Code: Create a comprehensive test suite covering all tenant isolation boundaries. Test that a user in Organization A cannot read Organization B's projects via API by guessing project IDs. Test that a user in Organization A cannot update or delete Organization B's data. Test that the organization switcher only shows organizations the user belongs to. Test that invitation codes for Organization A cannot be used to join Organization B. Test that role escalation is impossible — a member cannot make themselves an admin. Test that Stripe webhooks for Organization A update the correct organization and not a different one. Run the tests and verify all pass. Ask Claude Code: Perform a security audit of the entire application. Check for SQL injection in all query builders. Check for cross-tenant data access in every API route. Verify that all sensitive routes check authentication and authorization. Check that Stripe webhook signatures are verified. Verify that invitation codes are random and unpredictable. Check that passwords are hashed with bcrypt and never stored in plain text. Check that session tokens expire appropriately. Document any findings and fix all issues. For deployment, ask Claude Code: Prepare the application for production. Set up a PostgreSQL database on Railway. Create all tables with proper indexes — especially on org_id columns which appear in every query. Deploy the Next.js app to Vercel. Configure Stripe webhook endpoint for the production URL. Set all environment variables in Vercel. Test the complete flow: register, create organization, invite a member, create a project, create tasks, complete a payment, and verify billing limits. You now have the architectural foundation of every B2B SaaS product — multi-tenancy, authentication, authorization, billing, and data isolation.

Related Lesson

SaaS Architecture Patterns

This guide is hands-on and practical. The full curriculum covers the conceptual foundations in depth with structured lessons and quizzes.

Go to lesson