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.
By Suraj Ahmed
5th Aug 2026
Last updated: 5th Aug 2026
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.
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:
| Table | Purpose | Notable columns |
|---|---|---|
teams | Workspace entity | id, name, owner_id, subscription_tier |
team_users | Membership junction | team_id, user_id, role (admin/member; owner is derived) |
users | Account | selected_team_id (a fallback — see below) |
team_saas_plans | Billing per workspace | team_id UNIQUE, plan_id, subscription_status, stripe_subscription_id |
invitations | Referral / pending invites | invited_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_idin the database when removing users from teams or switching teams.selected_team_idin theuserstable 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.
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:
- Projects —
GET /api/user/projects/list?teamId=<id>filtersprojectsbyteam_idand by the requesting user's membership - Credits —
GET /api/team/credit?teamId=<id>sumscreditswhereteam_id = <id> - AI usage metering — inserts into
team_ai_request_logscarryteam_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.
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_usersrow 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 theteam_usersrow, 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:
- 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
commentstable keyed bycommentable_type(project/file/layer/message) supports threaded feedback anywhere in the app. - 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.
- 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-js2.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
Ready to build your app?
Turn your idea into a production-ready React Native app in minutes.
Free tools to get you started
Free AI PRD Generator
Generate a professional product requirements document in seconds. Describe your product idea and get a complete, structured PRD instantly.
Try it freeFree AI App Name Generator
Generate unique, brandable app name ideas with AI. Get creative name suggestions with taglines, brand colors, and monogram previews.
Try it freeFree AI App Icon Generator
Generate beautiful, professional app icons with AI. Describe your app and get multiple icon variations in different styles, ready for App Store and Google Play.
Try it freeFrequently 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.