Building Team Collaboration in an AI App Builder: 6 Decisions
By Riya
1st Jun 2026
Last updated: 1st Jun 2026
A collaborative AI app builder looks simple from the outside: invite teammates, share a project, edit together. Underneath, it's a tangle of permission models, billing scopes, state synchronization, and edge cases that quietly determine whether multi-player feels natural or fights the user at every turn.
When we built team collaboration into RapidNative, we made six architecture decisions that shaped everything that came after. Some are conventional. Two of them are unusual enough that engineers reviewing the codebase always ask about them. This post walks through each decision, the trade-off, and what we'd do again.
What is a collaborative AI app builder? It's a tool where multiple people can use natural language to generate, edit, and ship a single mobile app together — sharing prompts, components, comments, and credits inside a shared workspace. RapidNative turns plain English into real React Native and Expo apps, and team collaboration is what turns that from a solo toy into a workplace tool.
Photo by Annie Spratt on Unsplash
Decision 1: Tab-isolated team switching with sessionStorage
The most contrarian choice we made was where to store the "current team" pointer.
Most multi-tenant apps put it on the user record: users.selected_team_id. The frontend reads it on load, uses it for every query, and updates it whenever the user switches. Simple, single source of truth, done.
We do store selected_team_id on the user record, but it is not the source of truth. The source of truth at runtime lives in Redux, persisted to sessionStorage — and the API endpoint that updates the database is fire-and-forget.
Here's the actual flow inside appThunks.ts:
- On app load, check
sessionStorage.getItem('selectedTeamId'). - If empty, hit
/api/user/me, readselected_team_idfrom the DB, write it to sessionStorage, dispatch to Redux. - When the user picks a team in the switcher, write the new value to sessionStorage and Redux immediately. Then
POST /api/user/switch-teamin the background — and ignore failures.
Why? Because sessionStorage is tab-scoped. localStorage and database fields are shared across tabs. SessionStorage is not. That means a freelancer building three apps for three different clients can have Tab A in Client A's team and Tab B in Client B's team at the same time. They never need to switch back and forth or worry about accidentally generating code into the wrong workspace.
The trade-off: a user can be in a different team in each tab, which surprises engineers who expect "team" to be a global app state. Once we explained the rationale, every PM and designer we showed it to loved it. The CLAUDE.md in our repo carries a warning in red: never modify selected_team_id when removing users from teams. The database column is a fallback for first paint, not an authority.
A second-order benefit: switching teams doesn't require a page reload. Redux re-renders the project list, the credit balance, and the team switcher with the new team ID. The whole switch is a single dispatch — no nav, no flash.
Decision 2: Permissions as MAX of two layers in a Postgres function
Sharing in a team product gets messy fast. You want team-wide defaults ("the design team can view this project") and per-person overrides ("but Sarah can edit"). Most products pick one model or end up with permission logic spread across five service files.
We picked both — and pushed the consolidation into the database. The check_project_access(project_id UUID, user_email TEXT) Postgres function returns a JSON object with is_shared, access_level, is_owner, and is_public. Internally, it computes:
GREATEST(
-- User-specific access from project_share_team_user
CASE pstu.access_level
WHEN 'edit' THEN 2 WHEN 'view' THEN 1 ELSE 0 END,
-- Team-level access (only if user is still in the team)
CASE WHEN tu.id IS NOT NULL THEN
CASE ps.team_access_level
WHEN 'edit' THEN 2 WHEN 'view' THEN 1 ELSE 0 END
ELSE 0 END
)
Two tables drive this: project_share carries the team-wide default, and project_share_team_user carries per-member overrides. The user gets the maximum of the two. So a team-wide "view" with a personal "edit" lands on edit; a team-wide "edit" with a personal "restricted" still resolves to edit (you can't downgrade a team default with an override that's lower — overrides only go up).
The critical line is tu.id IS NOT NULL. It checks that the user is still in team_users. We added it after an early bug where removing a member from a team left their personal project-share row in place. They could still hit project URLs and see content. The fix wasn't a service-level check — it was tightening the SQL so the database itself refuses to grant access if team membership is gone.
Pushing this into a Postgres function does two things. First, every API route that needs to authorize a project view calls the same function, so there's exactly one place where the rule lives. Second, it works with Supabase Row-Level Security — the same function can be referenced from RLS policies, which means even direct PostgREST queries respect it. Authorization in two places is a bug factory. One place that the application layer and the database layer both agree on is much harder to break.
Photo by Taylor Vick on Unsplash
Decision 3: Polymorphic comments — and no realtime channels
The third decision will be unpopular with anyone who has read about CRDTs and Yjs. We have a comments system. We don't have realtime presence, live cursors, or websocket-driven comment broadcasts.
The comments table is polymorphic. Every row has a commentable_type ('project', 'file', 'layer', 'message', or 'comment') and a commentable_id (UUID). Replies are modeled as comments that point to a parent comment via parent_id. The source_data JSONB column carries context — x/y/width/height for a layer comment, file path for a file comment, layer path for a UI element comment. Soft delete via deleted_at keeps the audit trail intact.
That structure lets us support five distinct surfaces with one table and one fetch pattern. RTK Query (the data-fetching layer in Redux Toolkit) handles cache tags: each comment is tagged by its ID, and each list is tagged with a composite LIST-{type}-{id}. Posting a new comment invalidates the right list without touching unrelated caches.
What we don't do is push comments through Supabase realtime channels. Our package.json includes @supabase/phoenix and socket.io-client. Both sit unused on the comments path. Comments are fetched on demand when a project loads and refetched after a write.
Why? Three reasons, in order of importance:
- Real teams in an app builder don't co-edit the same screen at the same moment. They handle separate flows, review each other's work async, and leave comments to reconcile later. Live cursors look great in demos and create almost no value in actual workflows.
- Realtime channels are operationally expensive. Connection counts, reconnect storms, and presence GC are real costs. We'd rather spend that complexity budget on AI generation quality.
- Refetch-on-action is honest UI. When you post a comment, the list refetches and your comment appears. When you open a project, comments load fresh. There's no half-stale state to debug.
The cost is that two teammates commenting at the same second don't see each other's comments instantly. They see them on the next interaction or refresh. We've watched users for six months. Nobody has asked for live comment streaming. Several have asked for better comment threading and notifications, which is a much better signal of where to invest next.
If you're shipping a collaboration product, ask whether your users need true real-time multiplayer or whether they need clear, consistent state with quick refetches. The answer is almost always the latter.
Decision 4: Credits belong to teams, not users
Almost every consumer AI tool charges per user. They have a credit balance per account. When you upgrade, your account gets more credits. Simple, except for the moment a designer joins a startup's team and realizes she can't share her credits with the engineering teammate who's iterating on the same screen.
Credits in RapidNative live on the team, not the user. The saas_credits table has one row per team, and every AI generation across every member draws from the same pool. The Stripe subscription is also attached to the team via team_saas_plans. When a team owner upgrades to Pro, everyone in the team feels it instantly — there's no per-seat unlocking flow, no "you don't have credits, ask your owner" dance.
The credit pool itself has four buckets, all in the same row:
| Bucket | Refill cadence | Expires | Funded by |
|---|---|---|---|
free_credits | Monthly | 30 days | Free tier reset |
subscription_credits | Billing cycle | At cycle end | Active subscription |
non_expiring_paid | Never | Never | Top-up purchases |
non_expiring_free | Never | Never | Referrals, promos |
When a generation runs, the consumption priority drains them in the order above. That keeps the math fair: time-bound credits get spent before evergreen ones, so a user never loses non-expiring credit to a monthly reset they didn't notice. Every debit, refill, expiry, adjustment, and reset is written to saas_credit_transactions with a balance_after snapshot, which means we can answer "where did my credits go?" without replaying state.
Putting credits on the team also makes seat billing coherent. The Pro plan in saas_plans includes 10 members. Beyond that, has_seat_billing: true plus monthly_seat_stripe_price_id triggers a per-seat add-on through Stripe. The team owner pays. The team uses. No member-by-member accounting needed.
Photo by Daniel Korpai on Unsplash
Decision 5: Invitations as referral loops, not just access grants
The fifth decision was treating invitations as a growth surface, not plumbing.
The invitations table is small: invite_by, invited_user_email, status (pending | accepted | rewarded | expired). What's interesting is the lifecycle. When an invited user signs up and accepts, InvitationService.acceptInvitation() does five things:
- Looks up the invitation record.
- Verifies the inviter exists.
- Marks any other pending invitations for that email as expired (so multiple inviters can't all claim the bounty).
- Marks this specific invitation as
'rewarded'. - Adds 5 subscription credits to the inviter's team via
CreditService.addCredits(teamId, 'subscription', 5).
That last step is the whole point. Every accepted invite drops free generations into the inviter's pool. Over a team of ten, that compounds: an active inviter gets a steady drip of credits that softens their next billing cycle and makes inviting feel rewarded rather than transactional.
Email delivery runs through Resend with React Email templates. The invitation email carries the inviter's name and team name, which lifts open rates compared to "You've been invited" generic copy. The magic link auth flow (also handled by NextAuth with a custom magic-link provider) makes acceptance one click — new users sign up and join the team in the same step. We mark new users as isBetaUser: true automatically so they land in the team features on day one.
Treating invitations as a referral loop is a small choice with big downstream effects on growth. Most teams adopt the product because one person invited a teammate and it stuck. Putting a credit reward on that moment is cheap and aligned: we want more accepted invites; users want more credits.
Decision 6: Frontend Redux as truth, database as resume-point
The last decision is the one that ties everything together: where authoritative state lives.
The conventional answer is "the database is the source of truth." That's true for durable truth — projects, members, invitations, credit balances. But for current session truth — what team am I in right now, what project is open, which file am I editing — the conventional answer creates a hidden race condition.
A user clicks "switch team" in the team switcher. The dropdown shows a spinner. The frontend POSTs to /api/user/switch-team. The endpoint updates the DB. The endpoint returns 200. The frontend sets local state from the response and re-renders. Now imagine the POST fails — bad network, briefly unauthenticated, server timeout. What state is the user in? The dropdown shows the old team. The DB still has the old team. The user clicks again, gets another spinner. Frustration grows.
We flipped it. The frontend is the source of truth for current state:
- The team switcher writes to sessionStorage and Redux first, then fires the DB update as fire-and-forget.
- The UI updates immediately. The dropdown closes. The dashboard re-renders. No spinner.
- If the DB update fails, sessionStorage still has the right value. The next time the user logs in fresh, they'll get whatever the DB has — which is fine, because
selected_team_idis a resume-point, not a lock.
That mental model — database as resume-point, frontend as live truth — works for any state that's user-scoped and ephemeral. It's a worse fit for shared, durable data like projects and members, which is exactly why those go through normal request-response flows.
The cost is needing to be clear about which state is which. We document this in the project README and in CLAUDE.md so contributors don't accidentally code "update the DB first" patterns for ephemeral state. The benefit is a UI that never spins on its own state.
How does team collaboration work in an AI app builder?
In RapidNative, team collaboration runs on five primitives: a teams table, a team_users join table with roles (owner, admin, member), a project_share table for team-wide project access, a project_share_team_user table for per-member overrides, and a polymorphic comments table. Permissions are resolved in a single Postgres function. Credits, subscriptions, and seat billing all attach to the team via team_saas_plans. The frontend tracks the current team in Redux backed by sessionStorage, so a user can be in a different team in every browser tab.
What does role-based access control look like at the database level?
We use three roles in team_users.role: owner, admin, and member. Owners can do everything. Admins can manage members but not delete the team. Members consume credits and contribute work but don't manage seats. Project-level access (view, edit, restricted) is layered on top via project_share and project_share_team_user, and a Postgres function takes the MAX of team-level and per-user access to resolve what a given user can do on a given project.
Should you use realtime channels for team collaboration?
Probably not, unless your product is a true co-editing surface like a document editor. For most team-based SaaS products — including AI app builders, design tools, and project trackers — comments and changes work better with refetch-on-action than with live channels. Realtime adds operational cost (connections, reconnects, presence GC) that almost always exceeds the perceived UX gain.
The pattern behind the decisions
If you re-read the six decisions in order, a pattern shows up. Each one chose shape over completeness.
- Tab-isolated team state instead of one global team.
- Database-enforced permissions instead of service-layer checks scattered everywhere.
- Polymorphic comments without realtime instead of a real-time collaboration platform.
- Team-scoped credits instead of per-user accounting.
- Invitations as a growth loop instead of plain access plumbing.
- Frontend as live truth instead of DB-first writes for ephemeral state.
None of these are objectively right. They're choices that make sense for an AI app builder where teams are small (1-50 people), the unit of work is "a screen" or "a flow," and the value is being able to ship a mobile app faster than the design conversation finishes. If we were building a Google Docs competitor, several would flip — live cursors and CRDTs would suddenly matter.
The instinct to copy collaboration patterns from established tools (Figma, Notion, Linear) is strong. Resist it long enough to ask: which decisions made sense because of who their users are and what they're co-creating? Almost every collaboration decision is downstream of those two questions.
Photo by UX Indonesia on Unsplash
Try it on a real project
The fastest way to feel the difference is to bring a teammate into a project. Sign up at RapidNative, invite a collaborator from your team settings, and watch what happens when both of you generate screens against the same shared credit pool. The first time a designer hands a screen off to an engineer with a single comment thread — and the engineer hits "edit" without a Slack DM — is when the architecture stops feeling like architecture and starts feeling like the way collaboration was supposed to work.
If you're interested in the input modes that feed into a shared workspace, take a look at whiteboard-to-app, PRD-to-app, and image-to-app. All three flow through the same team-scoped credit and permission system described above. And our pricing page shows where the seat-billing thresholds kick in.
For more architecture deep-dives, the RapidNative blog has companion posts on how we stream AI-generated components and how we ship real-time live preview to every device.
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
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.