SaaS Multi-Workspace Architecture in 2026: Users in Multiple Orgs and Workspace Switching
Build a SaaS multi-workspace architecture: users belonging to multiple organizations, workspace switching UX, cross-workspace search, per-workspace billing, and data isolation.
SaaS Multi-Workspace Architecture in 2026: Users in Multiple Orgs and Workspace Switching
Users often belong to multiple organizations. A freelancer works across five client workspaces. An agency employee has their own company workspace plus client workspaces. A consultant joins workspaces to collaborate with different teams.
The multi-workspace pattern โ where a single user identity can belong to multiple isolated organizations โ is one of the more architecturally complex SaaS features. This post covers the data model, session-level workspace context, workspace switching UX, cross-workspace notifications, and per-workspace billing.
Data Model
The key insight: users and workspaces (organizations) have a many-to-many relationship. A user can be a member of multiple workspaces, with different roles in each.
-- Core entities
CREATE TABLE users (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
email TEXT NOT NULL UNIQUE,
name TEXT NOT NULL,
avatar_url TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
-- Workspaces (organizations, teams, tenants)
CREATE TABLE workspaces (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
name TEXT NOT NULL,
slug TEXT NOT NULL UNIQUE, -- URL-safe name: "acme-corp"
logo_url TEXT,
plan TEXT NOT NULL DEFAULT 'starter',
created_by UUID NOT NULL REFERENCES users(id),
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
-- Many-to-many: users โ workspaces
CREATE TABLE workspace_members (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
workspace_id UUID NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE,
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
role TEXT NOT NULL DEFAULT 'member',
joined_at TIMESTAMPTZ NOT NULL DEFAULT now(),
invited_by UUID REFERENCES users(id),
UNIQUE (workspace_id, user_id)
);
CREATE INDEX idx_wm_user ON workspace_members(user_id, joined_at DESC);
CREATE INDEX idx_wm_workspace ON workspace_members(workspace_id, role);
-- Track the user's last active workspace (for default on login)
ALTER TABLE users ADD COLUMN last_workspace_id UUID REFERENCES workspaces(id);
Session-Level Workspace Context
The active workspace is stored in the session, not a URL parameter. When the user switches workspaces, the session is updated:
// lib/auth/session.ts
import { getIronSession } from "iron-session";
import { cookies } from "next/headers";
export interface SessionData {
userId: string;
activeWorkspaceId: string;
// Cached for middleware โ avoid DB hit on every request
role: string;
email: string;
}
export async function getSession() {
const cookieStore = await cookies();
return getIronSession<SessionData>(cookieStore, {
password: process.env.SESSION_SECRET!,
cookieName: "session",
cookieOptions: {
secure: process.env.NODE_ENV === "production",
httpOnly: true,
sameSite: "lax",
maxAge: 60 * 60 * 24 * 30, // 30 days
},
});
}
export async function switchWorkspace(userId: string, workspaceId: string) {
// Verify user is actually a member of this workspace
const membership = await db.workspaceMember.findUnique({
where: {
workspaceId_userId: { workspaceId, userId },
},
select: { role: true },
});
if (!membership) {
throw new Error("Not a member of this workspace");
}
const session = await getSession();
session.activeWorkspaceId = workspaceId;
session.role = membership.role;
await session.save();
// Update last active workspace
await db.user.update({
where: { id: userId },
data: { lastWorkspaceId: workspaceId },
});
}
๐ SaaS MVP in 8 Weeks โ Seriously
We have launched 50+ SaaS platforms. Multi-tenant architecture, Stripe billing, auth, role-based access, and cloud deployment โ all handled by one senior team.
- Week 1โ2: Architecture design + wireframes
- Week 3โ6: Core features built + tested
- Week 7โ8: Launch-ready on AWS/Vercel with CI/CD
- Post-launch: Maintenance plans from month 3
Workspace Context in API Routes
// lib/auth/workspace-context.ts
import { cache } from "react";
import { getSession } from "./session";
import { db } from "@/lib/db";
export interface WorkspaceContext {
userId: string;
workspaceId: string;
role: string;
workspace: {
id: string;
name: string;
slug: string;
plan: string;
};
}
export const getWorkspaceContext = cache(async (): Promise<WorkspaceContext | null> => {
const session = await getSession();
if (!session.userId || !session.activeWorkspaceId) return null;
// Verify membership is still valid (could have been revoked)
const membership = await db.workspaceMember.findUnique({
where: {
workspaceId_userId: {
workspaceId: session.activeWorkspaceId,
userId: session.userId,
},
},
include: {
workspace: {
select: { id: true, name: true, slug: true, plan: true },
},
},
});
if (!membership) return null;
return {
userId: session.userId,
workspaceId: session.activeWorkspaceId,
role: membership.role,
workspace: membership.workspace,
};
});
Workspace Switcher UI
// components/WorkspaceSwitcher/WorkspaceSwitcher.tsx
"use client";
import { useState, useTransition } from "react";
import { useRouter } from "next/navigation";
import { switchWorkspaceAction } from "@/app/actions/workspace";
import { Plus, Check, ChevronDown } from "lucide-react";
interface WorkspaceMembership {
workspaceId: string;
workspaceName: string;
workspaceSlug: string;
workspaceLogo: string | null;
role: string;
}
interface Props {
currentWorkspaceId: string;
memberships: WorkspaceMembership[];
}
export function WorkspaceSwitcher({ currentWorkspaceId, memberships }: Props) {
const router = useRouter();
const [isOpen, setIsOpen] = useState(false);
const [isPending, startTransition] = useTransition();
const current = memberships.find((m) => m.workspaceId === currentWorkspaceId);
const handleSwitch = (workspaceId: string) => {
if (workspaceId === currentWorkspaceId) {
setIsOpen(false);
return;
}
setIsOpen(false);
startTransition(async () => {
await switchWorkspaceAction(workspaceId);
router.push("/dashboard"); // Navigate to dashboard of new workspace
router.refresh();
});
};
return (
<div className="relative">
<button
onClick={() => setIsOpen(!isOpen)}
disabled={isPending}
className="flex items-center gap-2 px-3 py-2 rounded-lg hover:bg-gray-100 transition-colors w-full text-left"
>
{current?.workspaceLogo ? (
<img
src={current.workspaceLogo}
alt={current.workspaceName}
className="h-6 w-6 rounded object-cover flex-shrink-0"
/>
) : (
<div className="h-6 w-6 rounded bg-blue-600 flex items-center justify-center flex-shrink-0">
<span className="text-white text-xs font-bold">
{current?.workspaceName[0]}
</span>
</div>
)}
<span className="text-sm font-medium text-gray-900 flex-1 truncate">
{current?.workspaceName ?? "Select workspace"}
</span>
<ChevronDown className={`h-4 w-4 text-gray-400 flex-shrink-0 transition-transform ${isOpen ? "rotate-180" : ""}`} />
</button>
{isOpen && (
<div className="absolute top-full left-0 right-0 mt-1 bg-white border border-gray-200 rounded-lg shadow-lg z-50 overflow-hidden">
<div className="py-1">
{memberships.map((membership) => (
<button
key={membership.workspaceId}
onClick={() => handleSwitch(membership.workspaceId)}
className="w-full flex items-center gap-3 px-3 py-2.5 hover:bg-gray-50 text-left"
>
{membership.workspaceLogo ? (
<img
src={membership.workspaceLogo}
alt={membership.workspaceName}
className="h-7 w-7 rounded object-cover flex-shrink-0"
/>
) : (
<div className="h-7 w-7 rounded bg-blue-100 flex items-center justify-center flex-shrink-0">
<span className="text-blue-700 text-sm font-bold">
{membership.workspaceName[0]}
</span>
</div>
)}
<div className="flex-1 min-w-0">
<p className="text-sm font-medium text-gray-900 truncate">
{membership.workspaceName}
</p>
<p className="text-xs text-gray-400 capitalize">{membership.role}</p>
</div>
{membership.workspaceId === currentWorkspaceId && (
<Check className="h-4 w-4 text-blue-600 flex-shrink-0" />
)}
</button>
))}
</div>
<div className="border-t border-gray-100 py-1">
<button
onClick={() => { setIsOpen(false); router.push("/workspaces/new"); }}
className="w-full flex items-center gap-2 px-3 py-2 text-sm text-gray-600 hover:bg-gray-50"
>
<Plus className="h-4 w-4" />
Create workspace
</button>
</div>
</div>
)}
</div>
);
}
๐ก The Difference Between a SaaS Demo and a SaaS Business
Anyone can build a demo. We build SaaS products that handle real load, real users, and real payments โ with architecture that does not need to be rewritten at 1,000 users.
- Multi-tenant PostgreSQL with row-level security
- Stripe subscriptions, usage billing, annual plans
- SOC2-ready infrastructure from day one
- We own zero equity โ you own everything
Server Action: Switch Workspace
// app/actions/workspace.ts
"use server";
import { switchWorkspace } from "@/lib/auth/session";
import { getSession } from "@/lib/auth/session";
import { revalidatePath } from "next/cache";
export async function switchWorkspaceAction(workspaceId: string) {
const session = await getSession();
if (!session.userId) throw new Error("Not authenticated");
await switchWorkspace(session.userId, workspaceId);
// Revalidate all cached data โ new workspace = different data scope
revalidatePath("/", "layout");
}
Loading All User Workspaces
// lib/workspace/memberships.ts
import { db } from "@/lib/db";
import { cache } from "react";
export const getUserWorkspaces = cache(async (userId: string) => {
return db.workspaceMember.findMany({
where: { userId },
include: {
workspace: {
select: {
id: true,
name: true,
slug: true,
logoUrl: true,
plan: true,
},
},
},
orderBy: [
{ workspace: { name: "asc" } },
],
});
});
Per-Workspace Billing
Each workspace has its own Stripe customer and subscription:
CREATE TABLE workspace_subscriptions (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
workspace_id UUID NOT NULL UNIQUE REFERENCES workspaces(id),
stripe_customer_id TEXT NOT NULL UNIQUE,
stripe_subscription_id TEXT UNIQUE,
plan TEXT NOT NULL DEFAULT 'starter',
status TEXT NOT NULL DEFAULT 'active',
current_period_end TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
// lib/workspace/create.ts
import Stripe from "stripe";
import { db } from "@/lib/db";
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!, {
apiVersion: "2024-06-20",
});
export async function createWorkspace(
userId: string,
name: string,
slug: string
) {
// Create Stripe customer for this workspace
const customer = await stripe.customers.create({
name,
metadata: { workspaceSlug: slug },
});
// Create workspace + owner membership + billing in a transaction
return db.$transaction(async (tx) => {
const workspace = await tx.workspace.create({
data: {
name,
slug,
createdBy: userId,
},
});
// Creator becomes the owner
await tx.workspaceMember.create({
data: {
workspaceId: workspace.id,
userId,
role: "owner",
},
});
// Initialize billing
await tx.workspaceSubscription.create({
data: {
workspaceId: workspace.id,
stripeCustomerId: customer.id,
plan: "starter",
status: "active",
},
});
return workspace;
});
}
Cross-Workspace Notifications
Users need to be notified of activity across all their workspaces, not just the active one:
// lib/notifications/cross-workspace.ts
// Notifications are scoped to user, not workspace
export async function sendCrossWorkspaceNotification({
userId,
workspaceId,
workspaceName,
eventId,
payload,
}: {
userId: string;
workspaceId: string;
workspaceName: string;
eventId: string;
payload: Record<string, unknown>;
}) {
// In-app notification always goes to user (regardless of active workspace)
await db.userNotification.create({
data: {
userId,
workspaceId,
eventId,
payload: {
...payload,
workspaceName, // So user knows which workspace this is from
},
},
});
// Email notification goes to user's global email
// Include workspace name in subject: "[Acme Corp] Task assigned to you"
}
URL Routing with Workspace Slug
Some SaaS products use workspace-in-URL (/acme-corp/dashboard) for clarity:
// app/[workspace]/layout.tsx
import { getSession } from "@/lib/auth/session";
import { db } from "@/lib/db";
import { notFound, redirect } from "next/navigation";
export default async function WorkspaceLayout({
children,
params,
}: {
children: React.ReactNode;
params: Promise<{ workspace: string }>;
}) {
const { workspace: slug } = await params;
const session = await getSession();
if (!session.userId) redirect("/login");
const workspaceData = await db.workspace.findUnique({ where: { slug } });
if (!workspaceData) notFound();
// Verify membership
const membership = await db.workspaceMember.findUnique({
where: {
workspaceId_userId: { workspaceId: workspaceData.id, userId: session.userId },
},
});
if (!membership) notFound(); // Don't reveal workspace exists to non-members
// If user has a different active workspace, sync session
if (session.activeWorkspaceId !== workspaceData.id) {
const { switchWorkspace } = await import("@/lib/auth/session");
await switchWorkspace(session.userId, workspaceData.id);
}
return <>{children}</>;
}
Cost and Timeline
| Component | Timeline | Cost (USD) |
|---|---|---|
| Data model + workspace CRUD | 1โ2 days | $800โ$1,600 |
| Session-level workspace context | 1 day | $600โ$1,000 |
| Workspace switcher UI | 1โ2 days | $800โ$1,600 |
| Per-workspace billing (Stripe) | 1โ2 days | $800โ$1,600 |
| Cross-workspace notifications | 0.5โ1 day | $400โ$800 |
| Full multi-workspace system | 2โ3 weeks | $10,000โ$18,000 |
See Also
- SaaS Role-Based Access โ Per-workspace role permissions
- SaaS Team Invitations โ Inviting users to a workspace
- SaaS White-Label โ Per-workspace custom branding
- PostgreSQL Row-Level Security โ Workspace-level data isolation at DB layer
Working With Viprasol
We build multi-workspace SaaS architectures for products serving enterprise customers with multiple teams. Our team has shipped multi-workspace systems where users routinely work across 5โ20 organizations.
What we deliver:
- Many-to-many user/workspace data model with per-workspace roles
- Session-based workspace context with secure workspace switching
- Workspace switcher UI with create-workspace flow
- Per-workspace Stripe billing with separate subscriptions
- Cross-workspace notification routing
Explore our SaaS development services or contact us to design your multi-workspace architecture.
About the Author
Viprasol Tech Team
Custom Software Development Specialists
The Viprasol Tech team specialises in algorithmic trading software, AI agent systems, and SaaS development. With 100+ projects delivered across MT4/MT5 EAs, fintech platforms, and production AI systems, the team brings deep technical experience to every engagement. Based in India, serving clients globally.
Building a SaaS Product?
We've helped launch 50+ SaaS platforms. Let's build yours โ fast.
Free consultation โข No commitment โข Response within 24 hours
Add AI automation to your SaaS product?
Viprasol builds custom AI agent crews that plug into any SaaS workflow โ automating repetitive tasks, qualifying leads, and responding across every channel your customers use.