How We Built Multi-Team Support in an AI App Builder

(155 chars):** How we built multi-team SaaS architecture into an AI app builder — the Redux + sessionStorage state pattern that survives every tab and refresh.

SA

By Suraj Ahmed

5th Aug 2026

Last updated: 5th Aug 2026

How We Built Multi-Team Support in an AI App Builder

Every SaaS team-collaboration blog post opens the same way: "we added a teams table and a join table." Then it ends. What none of them talk about is the harder question — when the current user belongs to four teams, which one are they acting as right now, in this browser tab, at this exact moment?

That question sounds trivial until you actually build it. It's the single decision that quietly shapes your entire multi-team SaaS architecture: it decides whether your API scoping is safe, whether your billing charges the right workspace, whether a designer in one tab can review a client project while pricing a proposal in another tab without leaking data between them. RapidNative, an AI mobile app builder, ships to freelancers running five client workspaces, agencies rotating between projects, and PMs who live in two teams at once — so we had to answer it carefully.

This post is a technical walkthrough of what we built: the data model, the three-layer state pattern (Redux → sessionStorage → database fallback) that a small warning in our internal CLAUDE.md file exists to protect, why per-tab isolation is a first-class requirement, and the parts of "real-time collaboration" we deliberately did not build.

Person working across two monitors with multiple browser tabs open One user, multiple client workspaces — the "which team am I in" problem is a runtime problem, not a data problem. Photo by Marvin Meyer on Unsplash

The requirement that most builders underestimate

The naïve mental model of "team" support looks like this: a user has one team, you scope everything to it, done. That works for exactly as long as your first pilot customer. The moment a real user shows up, they belong to multiple workspaces:

  • A freelancer building for three clients wants three isolated project lists, three separate credit balances, three separate billing surfaces
  • An agency running an internal team plus five client projects wants to spend from the client's plan when working on their app, not their own
  • A PM at a growing startup often belongs to a product-team workspace and a company-wide workspace at once

Once "which team am I acting as?" varies per request, per session, and per tab, the architecture stops being a data question and becomes a runtime state question. The database can tell you which teams a user belongs to. It cannot tell you which one they are right now.

The data model (the easy half)

The static part is boring in the good way. RapidNative's team schema lives in Supabase migrations under supabase/migrations/ and comes down to five core tables:

TablePurposeNotable columns
teamsWorkspace entityid, name, owner_id, subscription_tier
team_usersMembership junctionteam_id, user_id, role (admin/member; owner is derived)
usersAccountselected_team_id (a fallback — see below)
team_saas_plansBilling per workspaceteam_id UNIQUE, plan_id, subscription_status, stripe_subscription_id
invitationsReferral / pending invitesinvited_user_email, status

Every resource that a team can own carries a team_id foreign key: projects.team_id, credits.team_id, and a dedicated team_ai_request_logs table for usage metering. Cascade deletes propagate through all of them, and Row Level Security policies gate reads. This is the part every SaaS gets more or less right.

The role model is deliberately minimal. There is no enum column; team_users.role is plain TEXT with three effective values:

  • Owner — derived from teams.owner_id = user_id, not a role string
  • Admin — set explicitly in team_users.role
  • Member — default

The enforcement lives in one service (TeamMemberService) with rules chosen to prevent the annoying failure modes: an owner cannot be removed without transferring ownership, admins cannot demote or remove other admins, and when a member leaves their projects transfer to the team owner instead of being orphaned. That's it. No complex ACL, no permission matrix, no policy DSL. In two years of running this in production we've never wanted more.

The team-switching problem: where the "obvious" architecture fails

Now the interesting part. Imagine you've built the schema above and you're wiring up the frontend. A team switcher dropdown appears in the header. The user clicks Team B. What do you do?

The "obvious" answer is: UPDATE users SET selected_team_id = <team_b_id>. Then every subsequent request reads that column to know how to scope. Clean, single source of truth, database-authoritative. Ship it.

This is wrong, and the failure mode is very specific: a user cannot work in two teams simultaneously in two browser tabs. The moment Tab 2 flips them to Team B, Tab 1 — which was mid-edit on a Team A project — starts sending requests scoped to Team B, saving files to the wrong workspace, spending credits from the wrong billing account.

We saw this in the wild during a beta. A freelancer had a client's app open in one window and their own internal project in another, switched context, and their next generation charged the client. It was recoverable but embarrassing. That incident is why our repo's CLAUDE.md now carries this warning verbatim to future contributors and AI assistants:

NEVER modify selected_team_id in the database when removing users from teams or switching teams. selected_team_id in the users table is only a fallback for initial load. The source of truth for the current team is the frontend Redux store (runtime) and session storage (persistence across reloads).

That warning is the policy. The architecture below is what implements it.

Multiple browser windows open on a laptop screen Per-tab isolation is a feature, not an edge case — real users work across concurrent workspaces. Photo by Kevin Ku on Unsplash

The three-layer state pattern

RapidNative resolves "which team am I acting as?" through three layers, checked in this priority:

Layer 1 — Redux (runtime, per tab). The editor's Redux store contains an app slice with selectedTeamId: string | null. Every component that needs team context — the credits banner, the project list query, the billing modal — subscribes to this. When the user clicks a team in the switcher, we dispatch setSelectedTeamId(teamId) and every consumer re-renders. This is the fastest and highest-priority reader.

Layer 2 — sessionStorage (persistence, per tab). Redux state disappears on page refresh, so we mirror selectedTeamId into sessionStorage under the key selectedTeamId. On hydration we read it back into Redux. The critical property of sessionStorage (as opposed to localStorage) is that it's scoped to the browsing context: Tab 1 and Tab 2 have independent sessionStorage, so a switch in one tab does not affect the other. This is what buys us multi-tab isolation for free, and it's why we chose it. The utility itself is 20 lines:

// src/client/utils/teamStorage.ts
const STORAGE_KEY = 'selectedTeamId';

export const teamStorage = {
  get: (): string | null => {
    if (typeof window === 'undefined') return null;
    return sessionStorage.getItem(STORAGE_KEY);
  },
  set: (teamId: string): void => {
    if (typeof window === 'undefined') return;
    sessionStorage.setItem(STORAGE_KEY, teamId);
  },
  clear: (): void => {
    if (typeof window === 'undefined') return;
    sessionStorage.removeItem(STORAGE_KEY);
  },
};

Layer 3 — users.selected_team_id (database fallback). We still write to the database column, but only as a "what team did you last work in?" hint used exactly once: on first login in a brand-new browser session, when both Redux and sessionStorage are empty. After that first read, the frontend takes over. The /api/user/switch-team route does update this column, but nothing in the runtime request-scoping path reads from it.

The team-switching request pipeline ends up looking like this:

User clicks Team B in switcher
   │
   ├─▶ Redux: dispatch(setSelectedTeamId('team-b-id'))
   │       └─▶ every subscribed component re-renders scoped to Team B
   │
   ├─▶ sessionStorage: teamStorage.set('team-b-id')
   │       └─▶ survives page refresh in this tab only
   │
   └─▶ POST /api/user/switch-team { teamId: 'team-b-id' }
           └─▶ UPDATE users SET selected_team_id = 'team-b-id'
                   (fallback for future brand-new sessions)

Every API call the tab makes from that moment carries teamId as an explicit parameter — read from Redux, not inferred by the server from the database. The server verifies membership against team_users, then trusts the parameter. This is the single most important safety property in the whole architecture: the server never guesses the acting team; the client always declares it, and the server always verifies membership.

Why we accepted the maintenance cost

This pattern has a real cost. Every team-scoped endpoint takes an explicit teamId argument. Every hook that fetches team-scoped data has to know how to plumb the current selectedTeamId in. Every serverless function has to verify membership. It would be genuinely simpler to just read the column and be done.

The reason we accepted the cost: the class of bug the naïve version produces is silent data leakage between workspaces, and there is no defense-in-depth version of "we accidentally billed the wrong client." A wrong permission surfaces as a 403. A wrong workspace scope surfaces as a spent credit and a stray file in someone else's project. The former is a bug; the latter is a support incident and a trust event.

Team-scoped resources: projects, credits, subscriptions

Once "acting team" is a first-class parameter, scoping the rest is straightforward. Every resource query takes teamId:

  • ProjectsGET /api/user/projects/list?teamId=<id> filters projects by team_id and by the requesting user's membership
  • CreditsGET /api/team/credit?teamId=<id> sums credits where team_id = <id>
  • AI usage metering — inserts into team_ai_request_logs carry team_id, so per-team dashboards Just Work

One design detail worth calling out: when a user opens a project shared with them by another team, the credits they spend need to come from that team's plan, not their own. The fetchCredits thunk handles this by preferring projectTeamId (the team that owns the currently-open project) over selectedTeamId (the user's dashboard context). This is the only place in the codebase where a resource's team overrides the acting user's team — and it exists because if it didn't, an agency working on a client's project would drain their own credits instead of the client's paid plan.

Team-pooled billing: one Stripe subscription, N members

The team_saas_plans table stores one row per team with all of the Stripe state — stripe_customer_id, stripe_subscription_id, stripe_schedule_id, subscription_status, billing_cycle. When any team member clicks upgrade, the checkout session creates or updates that one row. The plan applies to the entire workspace. Every member consumes from the same credit pool. Downgrades scheduled through Stripe Billing Schedules land as pending_downgrade_at timestamps for end-of-cycle transitions.

The consequence: an agency owner subscribes to a Pro plan, invites five team members, and the plan and its credits are shared across the whole team without anyone else touching a payment page. This is unremarkable in isolation but it means all of our checkout, webhook, and cancellation code is written against team_id, not user_id, from the ground up. Retrofitting that later would have been miserable.

Team looking at a laptop together in an office One workspace, one plan, shared credits — team-scoped billing has to be the primitive, not a bolt-on. Photo by Annie Spratt on Unsplash

The invitation flow, and its one clever piece

Sending an invite is a POST /api/team/add-member call with an email. What happens next depends on whether that email already has a RapidNative account:

  • If the user exists: we insert a team_users row and send them a "you've been added to Team X" email
  • If the user doesn't exist: we create the account (marked isBetaUser: true), insert the team_users row, and send them a "you've been invited to Team X, click here to sign in" email

The second branch is the one that matters. It means an owner can add a new collaborator by email and that person's very next action — signing up — drops them straight into the team, no invitation-token dance, no "waiting for you to accept" state. There's a legacy invitations table in the schema for referral tracking, but it's not part of the primary invite path. The primary path optimizes for the case that actually happens 80% of the time: the invited person didn't have an account yet.

What we deliberately did not build

RapidNative's marketing site (specifically the "Made for teams" fold) says: "Everyone works on the same app at the same time. Changes appear instantly for the whole team." The honest truth is that the current implementation is shared workspace with async collaboration, not multi-cursor real-time editing. There is no Yjs, no CRDT, no operational transforms, no Supabase Realtime channel behind the editor canvas. Two team members editing the same project at the same time will see each other's changes on refresh, not live.

We looked at building CRDT-backed multi-cursor. We decided against it, for now, for three reasons:

  1. The primary collaboration surface isn't the canvas — it's the artifact. Teams generate a screen, hand it off, review it. That flow works with async review + comments. Comments are the one collaboration primitive that is first-class in our data model: a comments table keyed by commentable_type (project/file/layer/message) supports threaded feedback anywhere in the app.
  2. AI generation is single-user by nature. Two people can't productively co-drive a single prompt. Multi-cursor makes sense in Figma or a Google Doc where both users are directly manipulating pixels or text. In an AI builder, the collaboration happens between generations, not during them.
  3. The engineering cost is enormous. A production CRDT layer for a running React Native project (with routes, state, dependencies) is a six-month project on its own, and it would be the wrong bet against the actual bottleneck (generation speed) at RapidNative's current stage.

Being explicit about this in a technical blog matters because the answer isn't "we couldn't figure it out." The answer is "we deferred it, on purpose, and here's the async substitute (comments + share-and-preview + per-tab isolation) that ships the collaborative value without the six-month yak shave." A related design decision was covered in our post on how RapidNative delivers real-time preview across every device, which is the one place real-time updates were worth building.

The tech stack, briefly

For readers curious about the concrete dependencies powering all of this (from package.json):

  • Framework: Next.js 15.3.6 (App Router)
  • Database: Supabase (@supabase/supabase-js 2.57.4) — Postgres with RLS
  • Auth: NextAuth 4.24.11
  • State: Redux Toolkit 2.8.2 (for the runtime team context and editor state)
  • Billing: Stripe 18.2.1 (subscriptions, schedules, checkout)
  • Email: Resend 4.6.0 (invitations)

None of these are unusual choices. The interesting part isn't the stack — it's the state-flow policy on top of it.

People also ask

How should I let one user belong to multiple teams in a SaaS? Use a many-to-many junction table (team_users with team_id, user_id, role) and, critically, make "currently-acting team" a runtime parameter rather than a database column. The database tracks membership; the client tracks context. This lets one user work in multiple teams simultaneously across browser tabs.

Should the current team be stored in the database or the frontend? Store team membership in the database (a team_users junction) and current-team context in the frontend (Redux plus sessionStorage). If you make the database the source of truth for "acting team," a user cannot work in two teams at once in two browser tabs — every tab flips together, silently scoping work to the wrong workspace.

How do you scope Supabase RLS to a team? Enable RLS on every team-owned table, add a policy that checks EXISTS (SELECT 1 FROM team_users WHERE team_id = target.team_id AND user_id = auth.uid()), and pass teamId explicitly on every request rather than inferring it server-side. Explicit parameters prevent silent wrong-team scoping.

Closing

The interesting engineering in a multi-team SaaS architecture isn't the join table. It's the runtime state pattern that decides, on every request, which of a user's teams they're currently acting as — and the discipline of never letting the server guess. Get that right and the rest of the surface (billing per workspace, credits per pool, invitations that create accounts, comments as async collaboration) falls into place without contortions.

If you want to see what this feels like from the user side, try RapidNative free — describe an app in plain English, generate a real React Native project, and, if you invite a teammate, notice that opening the same account in a second tab lets you work in a different workspace independently. That behavior is not an accident. It's the whole architecture.

Related reading: Inside the RapidNative editor: how we built a browser-based AI app builder · Redux, Context, and beyond: state in RapidNative's AI editor · RapidNative's credit system explained · See pricing and team plans

External references: Supabase Row Level Security documentation · Redux Toolkit createSlice documentation · MDN: sessionStorage · Stripe Billing Schedules

Start now

Ready to build your app?

Turn your idea into a production-ready React Native app in minutes.

Free tools to get you started

Questions

Frequently asked questions

What is RapidNative?

RapidNative is an AI-powered mobile app builder. Describe the app you want in plain English and RapidNative generates real, production-ready React Native screens you can preview, edit, and publish to the App Store or Google Play.

Can I export the code?

Yes. RapidNative generates clean React Native and Expo code that you can export at any time. No lock-in, no proprietary format. Hand it to your developers or keep building inside RapidNative.

Is RapidNative free to use?

Yes. You can build apps on the free plan with no credit card required. Paid plans unlock unlimited AI generations, code export, and direct publishing to the App Store and Google Play.

Do I need to know how to code?

No. Most users build apps by describing what they want in plain English. Developers can drop into the code whenever they want more control, but coding is optional.

How long does it take to build an app?

Most users have a working first screen in under a minute. A full MVP usually takes a few hours instead of the weeks or months traditional development requires.