How to Add Team and Organization Management to a SaaS Boilerplate 2026
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
| Feature | Build Time |
|---|---|
| Database schema | 1 hour |
| Org context + auth | 2 hours |
| Invitation system | 3 hours |
| RBAC middleware | 2 hours |
| Per-seat billing | 3 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.