Skip to main content

Team & Org Management in SaaS Boilerplates 2026

·StarterPick Team
Share:

TL;DR

B2B SaaS requires team management: multiple users per account, roles, invitations, and org-level billing. Most boilerplates are single-user by default. Makerkit and Supastarter ship it pre-built. Everyone else needs to add it. From scratch: 11 hours. With Better Auth's organization plugin: 3 hours. With a multi-tenant boilerplate: 0 hours.


Team Features: The B2B SaaS Requirement

B2B SaaS products — those sold to companies rather than individuals — require team management: multiple users sharing an account, roles and permissions, member invitations, and billing per seat or per organization.

Most SaaS boilerplates are single-user by default. Makerkit and Supastarter include team features. Others require adding them manually.

The forcing function is simple: if any customer will be a company with more than one person using your product, you need team features. A solo indie hacker paying $9/month is a single user. A company buying for their 10-person marketing team expects to invite their colleagues. The moment you have B2B customers, the questions start: "Can I invite my colleague?" "Can I set my manager as admin?" "If I leave, what happens to our account?"

Building team management reactively — after you have business customers — is more work than building it upfront, because you need a data migration strategy for existing users. If B2B is in your roadmap, design for it from day one. Adding an organizationId column that's nullable initially is much cheaper than retrofitting it after your tables are full of production data.


When to Build vs When to Buy

The right choice depends on where you are in development and your auth library:

Use Makerkit or Supastarter if you're starting a new B2B SaaS project. Both ship with full team management including UI, invitation emails, role management, and org-level billing pre-wired. The $300-600 cost is offset by the 2-4 weeks of development time saved.

Use Better Auth's organization plugin if you're building on a boilerplate that uses Better Auth. The plugin handles the hard parts (invitation tokens, role checks, member management) in ~3 hours of integration work.

Build manually if you're already 3+ months into a project on NextAuth or Clerk, have an existing schema, and need to add org features without a full auth library migration. The patterns below are your path.

Use Clerk Organizations if you use Clerk for auth. Clerk's Organizations feature handles user-to-org mapping, roles, and invitations at the auth layer, reducing your database schema work to just the organizationId foreign key on resource tables.


Database Schema

The foundation: three tables linking users to organizations with roles.

// schema.prisma additions:
model Organization {
  id          String   @id @default(cuid())
  name        String
  slug        String   @unique
  plan        OrgPlan  @default(FREE)
  createdAt   DateTime @default(now())
  stripeCustomerId String?

  members     OrganizationMember[]
  invitations Invitation[]
}

model OrganizationMember {
  id             String       @id @default(cuid())
  userId         String
  organizationId String
  role           OrgRole      @default(MEMBER)
  joinedAt       DateTime     @default(now())

  user           User         @relation(fields: [userId], references: [id])
  organization   Organization @relation(fields: [organizationId], references: [id])

  @@unique([userId, organizationId])
}

model Invitation {
  id             String       @id @default(cuid())
  organizationId String
  email          String
  role           OrgRole      @default(MEMBER)
  token          String       @unique @default(cuid())
  expiresAt      DateTime
  acceptedAt     DateTime?
  createdAt      DateTime     @default(now())

  organization   Organization @relation(fields: [organizationId], references: [id])
}

enum OrgRole { OWNER ADMIN MEMBER VIEWER }
enum OrgPlan { FREE PRO ENTERPRISE }

Organization Context

Every request needs to know which organization the user is acting within:

// lib/org-context.ts
import { auth } from './auth';
import { db } from './db';
import { cache } from 'react';

export const getCurrentOrg = cache(async (orgSlug: string) => {
  const session = await auth();
  if (!session) return null;

  const membership = await db.organizationMember.findFirst({
    where: {
      userId: session.user.id,
      organization: { slug: orgSlug },
    },
    include: {
      organization: true,
    },
  });

  return membership || null;
});

// Use in server components and API routes:
export async function requireOrgAccess(orgSlug: string, minRole: OrgRole = 'MEMBER') {
  const membership = await getCurrentOrg(orgSlug);
  if (!membership) throw new Error('Not a member of this organization');

  const roles: OrgRole[] = ['VIEWER', 'MEMBER', 'ADMIN', 'OWNER'];
  if (roles.indexOf(membership.role) < roles.indexOf(minRole)) {
    throw new Error('Insufficient permissions');
  }

  return membership;
}

Invitation System

// lib/invitations.ts
import { db } from './db';
import { sendEmail } from './email';
import { addDays } from 'date-fns';

export async function inviteMember(
  orgId: string,
  email: string,
  role: OrgRole,
  invitedByName: string
) {
  // Check if already a member:
  const existingMember = await db.organizationMember.findFirst({
    where: {
      organizationId: orgId,
      user: { email },
    },
  });
  if (existingMember) throw new Error('User is already a member');

  // Create or update invitation:
  const invitation = await db.invitation.upsert({
    where: { email_organizationId: { email, organizationId: orgId } },
    create: {
      organizationId: orgId,
      email,
      role,
      expiresAt: addDays(new Date(), 7),
    },
    update: {
      role,
      token: crypto.randomUUID(),
      expiresAt: addDays(new Date(), 7),
      acceptedAt: null,
    },
  });

  const org = await db.organization.findUnique({ where: { id: orgId } });
  const inviteUrl = `${process.env.NEXT_PUBLIC_URL}/invitations/${invitation.token}`;

  await sendEmail({
    to: email,
    subject: `${invitedByName} invited you to join ${org!.name}`,
    body: `You've been invited to join ${org!.name}. Click here to accept: ${inviteUrl}`,
  });

  return invitation;
}

// Accept an invitation:
export async function acceptInvitation(token: string, userId: string) {
  const invitation = await db.invitation.findUnique({ where: { token } });

  if (!invitation) throw new Error('Invitation not found');
  if (invitation.acceptedAt) throw new Error('Already accepted');
  if (invitation.expiresAt < new Date()) throw new Error('Invitation expired');

  await db.$transaction([
    db.organizationMember.create({
      data: {
        userId,
        organizationId: invitation.organizationId,
        role: invitation.role,
      },
    }),
    db.invitation.update({
      where: { id: invitation.id },
      data: { acceptedAt: new Date() },
    }),
  ]);
}

RBAC Middleware

// middleware.ts — organization route protection:
export function middleware(request: NextRequest) {
  const { pathname } = request.nextUrl;

  // Extract org slug from URL: /org/[slug]/...
  const orgSlugMatch = pathname.match(/^\/org\/([^/]+)/);
  if (orgSlugMatch) {
    request.headers.set('x-org-slug', orgSlugMatch[1]);
  }

  return NextResponse.next({ request });
}
// In API routes — check permissions:
export async function DELETE(
  req: Request,
  { params }: { params: { orgSlug: string; memberId: string } }
) {
  const membership = await requireOrgAccess(params.orgSlug, 'ADMIN');

  // Admins can remove members (but not owners):
  const target = await db.organizationMember.findUnique({
    where: { id: params.memberId },
  });

  if (target?.role === 'OWNER') {
    return new Response('Cannot remove organization owner', { status: 403 });
  }

  await db.organizationMember.delete({ where: { id: params.memberId } });
  return new Response(null, { status: 204 });
}

Per-Seat Billing with Stripe

// Billing based on member count:
export async function updateOrgSubscription(orgId: string) {
  const org = await db.organization.findUnique({
    where: { id: orgId },
    include: { members: true },
  });

  if (!org?.stripeCustomerId) return;

  const seatCount = org.members.length;

  // Update Stripe subscription quantity:
  const subscription = await stripe.subscriptions.list({
    customer: org.stripeCustomerId,
    status: 'active',
    limit: 1,
  });

  if (subscription.data.length > 0) {
    await stripe.subscriptions.update(subscription.data[0].id, {
      items: [{
        id: subscription.data[0].items.data[0].id,
        quantity: seatCount,
      }],
    });
  }
}

Better Auth: Organizations Built-In

If you use Better Auth, organizations are a built-in plugin:

import { betterAuth } from 'better-auth';
import { organization } from 'better-auth/plugins';

export const auth = betterAuth({
  plugins: [
    organization({
      allowUserToCreateOrganization: true,
      membershipRequests: true,
    }),
  ],
  // ... rest of config
});

// Client:
import { organizationClient } from 'better-auth/client/plugins';
const { organization } = createAuthClient({ plugins: [organizationClient()] });

await organization.create({ name: 'Acme Corp', slug: 'acme' });
await organization.inviteMember({ email: 'colleague@acme.com', role: 'member', organizationId });

Better Auth handles invitations, roles, and member management out of the box.


Org Switcher UI

For users who belong to multiple organizations, an org switcher in the navigation is required. The pattern: a dropdown in the navbar that shows all the user's organizations and lets them switch between them.

The switcher needs to:

  • Show the current active organization (name + avatar)
  • List all organizations the user belongs to
  • Redirect to the new org's dashboard on switch
  • Include a "Create new organization" option

The cleanest implementation stores the current org as part of the URL (/org/[slug]/dashboard) rather than in session or localStorage. URL-based org context means bookmarks work, links are shareable within the org, and the server always knows which org a request is for without a database lookup on every session read.


Common Mistakes

Forgetting organizationId on new resource tables: Every model that stores per-org data needs this column. The first time you add a feature and forget it, you have a data isolation bug. Make organizationId the first column you add to any new model.

Unscoped mutations: Queries that fetch org data get the organizationId filter added correctly. DELETE and UPDATE queries are forgotten more often. An unscoped delete is a critical security vulnerability — user A deletes user B's record by guessing the ID.

Last owner edge case: When the last OWNER of an org tries to leave or delete their account, block the action and require transferring ownership first. Without this guard, organizations can become ownerless.

Billing on the wrong entity: For B2B SaaS, the Stripe Customer and subscription must be attached to the organization, not the individual user. If the user who set up billing leaves the org, the subscription should stay with the org.


Time Estimates

FeatureBuild Time
Database schema1 hour
Org context + auth2 hours
Invitation system3 hours
RBAC middleware2 hours
Per-seat billing3 hours
Total from scratch~11 hours
Using Better Auth plugin~3 hours
Using Makerkit (pre-built)~0 hours

For B2B SaaS needing team management, Makerkit ($299) saves significant development time.


Org Switcher UI Patterns

The org switcher is the navigation element that lets users move between organizations. The design decision is whether to use URL-based organization routing (/org/acme/dashboard) or session-based routing (/dashboard with current org stored in session/localStorage).

URL-based routing (recommended for B2B products with multiple teams): Each org's data lives at a distinct URL. Bookmarks work, links are shareable within the org, and the server always knows which org context to use without reading session state. The URL structure /org/[slug]/[section] maps cleanly to Next.js nested routing.

Session-based routing: The current org is stored in the session or a cookie. All users see /dashboard regardless of which org they're in. Simpler to implement, but links aren't shareable between orgs and the server needs an extra database lookup per request to get the current org context.

The org switcher component itself should:

  • Show the current org name and avatar/logo prominently
  • Dropdown with all orgs the user belongs to (sorted by most recently accessed)
  • Visual indicator of the user's role in each org (OWNER/ADMIN/MEMBER)
  • "Create new organization" option at the bottom
  • When switching, redirect to the equivalent page in the new org (e.g., switching orgs on /org/acme/projects takes you to /org/new-org/projects)

Testing Multi-Tenant Isolation

The most dangerous bug in multi-tenant applications is data leakage: user A seeing user B's data. This class of bug is easy to miss in manual testing because you're usually testing as a single user.

Testing multi-tenant isolation requires integration tests with multiple users:

// test/multi-tenant-isolation.test.ts
test('user cannot access another org\'s data', async () => {
  const orgA = await createTestOrg({ name: 'Org A' });
  const orgB = await createTestOrg({ name: 'Org B' });
  
  const userA = await createTestUser({ orgId: orgA.id });
  const userB = await createTestUser({ orgId: orgB.id });
  
  // Create a project in Org A
  const project = await createTestProject({ orgId: orgA.id });
  
  // User B should NOT be able to access Org A's project
  const response = await apiClient
    .asUser(userB)
    .get(`/api/projects/${project.id}`);
  
  expect(response.status).toBe(404); // Not 403 — don't reveal the resource exists
});

Run these isolation tests as part of your test suite. They should cover: reading resources, updating resources, and deleting resources. A 404 (not 403) on cross-org access is the preferred behavior — it doesn't reveal to a potential attacker that the resource exists.


For boilerplates that ship multi-tenancy pre-built with full UI, see best boilerplates for multi-tenant SaaS. For a deeper dive into the full multi-tenancy migration (including retrofitting existing single-tenant data), see how to add multi-tenancy to any boilerplate. For white-label SaaS on top of multi-tenancy (custom domains per org), best boilerplates for white-label SaaS adds the custom domain layer.


Notification Architecture for Org Events

Multi-tenant products generate org-scoped notifications: a new member joins, someone comments on your document, a task is assigned to you. The notification system needs to scope notifications to the correct organization so users don't see activity from other orgs.

The core pattern: every notification has userId (recipient), orgId (which org it belongs to), type (the event), and payload (relevant data). Notification feeds in the UI filter by the current active org. When a user switches orgs, their notification count updates to reflect the unread count in the new org context.

Delivery channels: in-app notification bell (always implement first — lowest complexity), email digest (implement second — useful for async collaboration), and push notifications or Slack integration (implement only if users request them — these require significantly more infrastructure).


Member Removal and Ownership Transfer

Two edge cases that most implementations handle incorrectly:

Member removal: When an admin removes a member, their data (projects, documents, records) stays in the organization. The member loses access; their work doesn't disappear. This is the correct behavior — removing a team member who has created work should not delete that work. If the departing member was assigned to active tasks or projects, send a notification to the org admin to reassign.

Ownership transfer: The OWNER role is special — only one person should typically hold it at a time, and it cannot be removed without transferring to another user. If the owner wants to leave the org, they must first promote another ADMIN to OWNER. Block the "leave org" action for owners with an error: "You must transfer ownership before leaving." Provide a UI to transfer ownership to any current ADMIN.

If an organization becomes ownerless (e.g., the owner's account is deleted without transferring first), you need a recovery path. Options: auto-promote the longest-tenured ADMIN, or flag the org as needing a recovery action and notify all ADMINs.


Methodology

Implementation patterns based on Makerkit and Supastarter source code (both use similar multi-tenant architectures). Better Auth organization plugin from official Better Auth documentation (v1, 2026). Time estimates based on community reports in the ShipFast Discord.

Find boilerplates with organization management built-in at StarterPick.

The SaaS Boilerplate Matrix (Free PDF)

20+ SaaS starters compared: pricing, tech stack, auth, payments, and what you actually ship with. Updated monthly. Used by 150+ founders.

Join 150+ SaaS founders. Unsubscribe in one click.