Back to Blog

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.

Viprasol Tech Team
February 5, 2027
14 min read

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

ComponentTimelineCost (USD)
Data model + workspace CRUD1โ€“2 days$800โ€“$1,600
Session-level workspace context1 day$600โ€“$1,000
Workspace switcher UI1โ€“2 days$800โ€“$1,600
Per-workspace billing (Stripe)1โ€“2 days$800โ€“$1,600
Cross-workspace notifications0.5โ€“1 day$400โ€“$800
Full multi-workspace system2โ€“3 weeks$10,000โ€“$18,000

See Also


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.

Share this article:

About the Author

V

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.

MT4/MT5 EA DevelopmentAI Agent SystemsSaaS DevelopmentAlgorithmic Trading

Building a SaaS Product?

We've helped launch 50+ SaaS platforms. Let's build yours โ€” fast.

Free consultation โ€ข No commitment โ€ข Response within 24 hours

Viprasol ยท AI Agent Systems

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.