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
Recommended Reading
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 |
Additional Resources
- 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
Partnering 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.
External Resources
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 1000+ projects delivered across MT4/MT5 EAs, fintech platforms, and production AI systems, the team brings deep technical experience to every engagement.
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.