Skip to main content

How to Add Team and Organization Management to a SaaS Boilerplate 2026

·StarterPick Team
multi-tenantorganizationsrbacsaas-boilerplatenextjshow-to2026

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.


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.


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.

StarterPick helps you find boilerplates with organization management built-in.

Comments