How We Built Team Collaboration into an AI App Builder

PA

By Parth

21st Apr 2026

Last updated: 21st Apr 2026

Most AI app builders are built for a solo developer with a laptop and a caffeine problem. You open a chat, describe a screen, hit enter, and the model produces a React Native component. That loop works beautifully — until a second person needs to open the same project.

Then things get complicated fast. Who owns the credits? Who can edit the code? What happens when two people type at once? How do you make Stripe bill the right entity? And how do you prevent the guy you invited to look at a prototype from accidentally shipping to production?

This post is the engineering story of how we retrofitted team collaboration into RapidNative — a collaborative AI app builder that turns natural language into working React Native and Expo apps. We'll walk through the data model, the subtle "fallback" pattern we use for team context, how sharing permissions actually resolve, the Socket.IO layer that keeps editors in sync, and the billing surgery we had to do to make per-team subscriptions feel invisible.

If you're building any multi-tenant product on Next.js and Supabase, a few of these patterns will probably save you a week.

Teams Are the Unit of Everything (Not Users)

The first architectural decision was the most important: every resource belongs to a team, not a user. Projects, credits, messages, AI usage, Stripe subscriptions — all of it is scoped to team_id.

That sounds obvious in retrospect. It was not obvious on day one. RapidNative originally shipped as a single-player tool. Users created projects. Projects belonged to users. Credits lived on the user row. This is the cheapest way to build an app, and it's also the reason so many SaaS products have a painful "convert solo → team" migration story.

We eventually paid that bill. Our current schema (in supabase/migrations/) looks like this:

  • teamsid, name, description, owner_id, timestamps. One row per workspace.
  • team_users — join table with team_id, user_id, role (owner / admin / member).
  • users.selected_team_id — a pointer to the team a user is "currently" working in. More on why this is a fallback, not a source of truth, in a minute.
  • projects.team_id — every project is team-scoped. Two users in the same team share a project list.
  • saas_credits with team_id — credits are pooled per team.
  • team_saas_plans — a dedicated billing table. One subscription per team.
  • invitations — pending invites with invite_by, invited_user_email, and a state enum.
  • project_share and project_share_team_user — fine-grained share rules (covered below).

The team_users.role enum is deliberately small. We looked at products with six roles (owner, admin, billing-only, editor, commenter, viewer) and decided 90% of that surface area is noise for a builder tool. Three roles, plus per-project overrides, cover everything our customers actually ask for.

The Fallback Pattern: Why the Database Isn't the Source of Truth

Here's the first non-obvious pattern, and the one that trips up every new engineer who joins the codebase: users.selected_team_id is not authoritative.

The authoritative "current team" lives in two places:

  1. The frontend Redux store (appSlice.selectedTeamId in src/modules/editor/store/slices/appSlice.ts).
  2. Browser sessionStorage, which persists across reloads.

The database column is a last-resort fallback — used only when sessionStorage is empty (first visit, cleared cache, private window). That design was not an accident; it was a bug report.

Early on, we stored the current team only in the database. A user switched teams in one browser tab, and every other open tab silently flipped to the new team. Projects disappeared from their editor. Credits changed. Stripe billing context shifted. From the user's perspective, the product had amnesia.

The fix: team context is per-tab. Each tab gets its own sessionStorage entry. You can have Team A open in one tab and Team B in another, and they never leak into each other. The POST /api/user/switch-team endpoint still updates users.selected_team_id, but only as a recovery hint — never as a trigger.

This principle is now enforced in our internal docs and, honestly, taped to the wall metaphorically:

Never modify selected_team_id when removing users from teams or switching teams. The frontend owns it. The database only hints.

If you're building multi-workspace software, take this one home. The browser is the source of truth for which workspace; the database is the source of truth for what's in the workspace.

Scoping Every Resource to team_id

Once teams are the unit, scoping is mostly plumbing. Every query that lists projects, pulls credits, or fetches messages takes a team_id parameter. We enforce this in two layers:

  • Repository layer — functions like TeamSaasPlanRepository.findByTeamIdWithPlan() and CreditService.getTeamCredits(teamId) accept a teamId as the first argument. There's no "current user's credits" helper. You must specify the team.
  • Supabase RPCs — for hot paths (project lists, access checks) we use PL/pgSQL functions like get_accessible_projects(p_team_id, p_user_id) and check_project_access(...). Pushing these joins into the database removes N+1 queries and lets us cache at the Postgres layer.

The second choice was contentious. RPCs are harder to refactor than TypeScript and harder to test in isolation. But the alternative — a chain of Supabase query builder calls on every request — was measurably slower and leaked access-control logic all over the app. RPCs gave us one place to change the rules and one query to execute.

We did put a small firewall in front: every RPC is wrapped by a repository method that validates inputs with Zod before passing them through. Nothing hits Postgres without a schema check.

Sharing: Two Layers, Resolved with GREATEST()

Per-project sharing is where most collaboration systems collapse under their own rules. Ours is deliberately boring — two levels, one resolution function.

Level 1 — team-wide default (project_share.team_access_level): what every team member gets on this project. Options: restricted, view, edit.

Level 2 — per-user override (project_share_team_user.access_level): for individual team members who should have more (or less) access than the team default. Same three values.

Resolving these is a single SQL expression inside the check_project_access RPC:

GREATEST(
  CASE pstu.access_level
    WHEN 'edit' THEN 2 WHEN 'view' THEN 1 ELSE 0 END,
  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
)

Three things are worth pointing out:

  1. GREATEST means "most permissive wins." We considered the opposite — least permissive — and rejected it. In practice, users expect adding a per-user override to grant access, not remove it. If you need to deny access, set restricted explicitly.
  2. Team access is gated on tu.id IS NOT NULL. This was a bug fix. Previously, when you removed someone from a team, project_share.team_access_level still applied to them because the share row was team-wide. They kept seeing projects until we remembered to scrub the share row. Now team-wide access is only meaningful while you are in the team. Removing a user instantly revokes their team-level access without touching any share rows.
  3. Owner beats everything. The RPC also returns is_owner and is_public flags; the client layer gives owners a bypass and public projects render without auth.

The migration that added tu.id IS NOT NULL20251204140000_update_check_project_access_team_membership.sql — is a good reminder that access control is something you finish iterating on about a month after you thought you did.

Invitations: A Small State Machine That Carries Real Weight

Invitations look trivial. They aren't.

Our invitations table has four states: pending, accepted, rewarded, and expired. A new invite starts at pending. When the invitee signs up through the link, it moves to accepted. If we've credited the inviter with a referral bonus, it moves to rewarded. Stale invites go to expired.

The POST /api/team/add-member endpoint does four things in sequence, and each step has failed for us at least once in production:

  1. Verify the inviter is a member of the team. (Once, a bug let non-members invite people to teams they didn't belong to. That was an interesting day.)
  2. Check if the invitee already exists as a user. If yes, we add them directly to team_users. If no, we create a user row with isBetaUser=true so they're billing-aware on first login.
  3. Reject duplicate memberships. Cheap SELECT before INSERT.
  4. Send an email via Resend using the sendTeamInvitationEmail() template.

Two things we got wrong the first time and corrected: idempotent inserts (to survive retry storms), and a UNIQUE constraint on (team_id, user_id) so that the DB refuses duplicates even when application logic forgets to.

Real-Time Sync: Socket.IO, Not Operational Transform

Collaborative editors — Figma, Google Docs, Notion — are famous for elaborate real-time protocols: OT, CRDT, server reconciliation, vector clocks. We did not build that.

We built something much simpler, and it turns out to be the right call for an AI app builder.

RapidNative's unit of collaboration is the file — typically a single React Native screen. Edits happen at the file level, not the character level. When a user saves a file, we emit a Socket.IO event. Every other client on the same project receives it and dispatches a Redux action (saveFile, updateFileThunk, deleteFileThunk) that re-reads the file from storage.

The implementation lives in src/services/sync-service.ts and leans on two ideas:

  • Project-scoped rooms. Each connected client joins a room keyed by projectId. Events only fan out inside the room.
  • Tab IDs. Every tab generates a unique ID stored in sessionStorage. When a tab emits an event, it tags it with its own tab ID, and when it receives an event, it filters out events carrying the same tab ID. This prevents self-echo without needing the server to track who sent what.

Presence — "who's in this project right now" — is a thin layer on top. A setActiveUsers socket message broadcasts the list of connected userIds in the room whenever someone joins or leaves. The UI uses it to render the familiar stack of avatar circles.

What we deliberately don't do:

  • No per-keystroke sync. The AI workflow is "describe → generate → preview." Fine-grained character collaboration doesn't add value and complicates conflict resolution dramatically.
  • No server-side OT. The last save wins. Every file has a version column, and a stale save gets a 409. In 18 months we've had fewer than 5 user-facing conflicts, because AI-driven edits are bursty and single-authored.

If you're considering real-time collaboration in a similar tool, start with file-level sync and move down only when users complain. You might never need to.

Polymorphic Comments: One Table, Any Target

Comments are where collaboration gets social. You want to leave a note on a button. Or on a line in a file. Or reply to another comment. Or attach a screenshot.

We handle all of this with one table using a polymorphic pattern:

  • comments.commentable_type'project', 'file', 'layer', 'message', or 'comment'.
  • comments.commentable_id — the UUID of whatever that thing is.
  • comments.project_id — denormalized, added later so we can answer "all comments in this project" without a recursive join.
  • comments.parent_id — self-referential, for threaded replies.
  • comments.source_dataJSONB, for layer coordinates, text selection ranges, or attachments.
  • comments.deleted_at — soft delete, because comments carry conversation context.

Access control for comments inherits from the project. If you have read access to the project, you can read comments. If you have edit, you can post. This kept the permission story simple — we didn't want a separate comments ACL.

Comments currently use HTTP + Redux Query (cache-tagged with { type: 'Comment', id }) instead of Socket.IO. Polling on an invalidation event is fine; comments don't have the sub-second-latency expectations of cursors or file edits. We'll move them onto the socket layer when volume demands it, not before.

Billing: Why team_saas_plans Is Its Own Table

Early versions of RapidNative had a teams.subscription_tier column. It was a mistake. Billing state is its own domain with its own lifecycle — trials, prorations, schedules, webhooks — and squeezing it into the teams table meant every schema change to billing touched the canonical team record.

So we extracted it into team_saas_plans:

  • One row per team (UNIQUE on team_id).
  • A plan_id foreign key to the saas_plans catalog.
  • subscription_status, billing_cycle, and every Stripe ID we care about: stripe_customer_id, stripe_subscription_id, stripe_schedule_id, stripe_seat_item_id, stripe_base_item_id.
  • pending_downgrade_plan_id and pending_downgrade_at for scheduled plan changes.

Credits flow into saas_credits (also team-scoped) with three buckets: subscription_credits (paid allotment), free_credits (daily/monthly cap), and non_expiring_free (referral bonuses and founder grants). Every deduction writes a row to saas_credit_transactions — an append-only audit log we mine for analytics and support tickets.

The design principle: billing data is append-only. Credits get deducted by inserting a transaction, never by mutating a balance. The "balance" is a derived view. This makes every support conversation ("why did I get charged?") a straight read from the log.

See our pricing page for what these plans actually look like in the product.

Middleware, Auth, and the Team-Context Header

The last layer is authentication. We use NextAuth with JWT sessions. Every request to /api/user/* goes through src/middleware.ts, which:

  1. Validates the JWT.
  2. Looks up the user's email on the token.
  3. For admin routes, cross-references against a whitelist.
  4. Sets x-user-email and x-user-id headers for downstream route handlers.
  5. Allows IP-allowlisted "super-user" requests to bypass auth for internal tools.

Route handlers then call UserRepository.findByEmail(email) to read selected_team_id as a fallback if the client didn't pass a team hint. The frontend, when it knows better, sends team context in the request body — and that always wins.

One sharp edge we hit: NextAuth originally reset selected_team_id on every login. That was surprising behavior ("I was working in Team B, then I re-logged in and I'm in Team A again"). We removed the reset — the comment still lives in the auth route as a note to future maintainers — and team context now survives across sign-ins the way users expect.

What We'd Do Differently

A few honest takeaways after 18 months of running this in production:

  • Start team-scoped on day one if there's even a 10% chance you'll add collaboration later. The migration from user-scoped to team-scoped is painful. Every table, every query, every test.
  • Use UUIDs from the beginning — not TEXT IDs, not ints. projects.team_id started as TEXT and we still haven't finished the type migration.
  • Put access control in the database. Every RPC we added (check_project_access, get_accessible_projects) removed a class of bugs and made code review easier. ACL logic in TypeScript drifts; ACL logic in PL/pgSQL stays put.
  • Treat selected_team_id as a hint, not a source of truth — forever. The browser knows; the DB recovers.
  • Audit everything billing-related. Support queries vanish when your answer is "here's the exact row, from this webhook, at this timestamp."

Try It Yourself

If you want to see this architecture in action, the best way is to start a free project and invite a teammate. Start from a prompt, sketch, or PRD, generate a React Native screen, and watch the presence indicators light up as your teammate joins. The invitation flow, the fallback team context, the Socket.IO sync, the GREATEST() access resolution — all of it runs silently in the background, which is exactly how collaboration should feel.

We also write about other parts of the product — check out the piece on how point-and-edit visual editing works or browse the full engineering blog for more architecture deep-dives.

Build better, together. That's the whole point of a collaborative AI app builder — and it turns out it takes more than a chat box to get there.

Ready to Build Your App?

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

Try It Now

Free tools to get you started

Frequently 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.