How We Built the Real-Time Sync Layer for Team App Building

RI

By Rishav

17th Sep 2026

Last updated: 17th Sep 2026

How We Built the Real-Time Sync Layer for Team App Building

The moment two teammates open the same RapidNative project, something has to give. One prompts the AI to add a screen. Files start streaming into the virtual file system. The preview iframe hot-reloads. Chat messages flip from streaming to complete. If the second teammate’s editor doesn’t reflect any of that within a few hundred milliseconds, the product feels broken — and worse, the two of them silently drift out of sync until one overwrites the other.

Getting that right is a real-time collaboration architecture problem, and it’s the kind of problem where the interesting decisions aren’t about "which library" — they’re about which events you broadcast, how you authenticate them, and how you keep a client from applying its own echoes. This post is a walkthrough of the actual sync layer we ship: a dedicated Socket.IO service that sits next to our Next.js app, mirrors every file and message across every tab in a project room, and drives the presence avatars in the editor header.

Two people looking at a shared laptop screen collaborating on code Photo by Annie Spratt on Unsplash

What real-time collaboration actually needs to do in an AI app builder

Real-time collaboration architecture in an AI app builder has to solve three problems at once: mirror a virtual file system across every tab in a project room within a few hundred milliseconds, keep every viewer’s chat thread converged as messages transition through streaming and terminal states, and expose who is currently in the room so nobody edits into an empty seat. It is closer to a live document than a chat app, and closer to a streaming pipeline than a document.

That last point is where most "how to build collab" posts fall over. A Google Docs-style architecture assumes both users are typing keystrokes. In our editor, most edits don’t come from a human — they come from an AI generation that writes a dozen files over 90 seconds while multiple teammates watch. So "real-time" here means the AI’s side of the conversation is a first-class multi-user event, not just human input.

That reframing changes every downstream decision: what to broadcast, how to authenticate, and how to reconcile.

Why a dedicated sync server, not Supabase Realtime

RapidNative’s data plane is Supabase. Projects, files, messages, teams, and share permissions all live in Postgres with row-level security, and Supabase Realtime can stream postgres_changes events from any table. It was the obvious first thing to try.

We didn’t use it. Instead, we run a small stateful Node service — rapidnative-sync-server — that talks Socket.IO to the browser and REST to the Next.js app. The reasons are worth spelling out, because they’ll be the same reasons on any similar system.

ConcernSupabase RealtimeDedicated sync server
Auth modelJWT-in-URL, table-level RLSCustom two-stage handshake through /api/sync/validate
Event shapeRow-shaped (postgres_changes)Domain-shaped (files:updated, messages:updated, presence:update)
Echo suppressionClient filters by row contentPayload carries a tabId; client drops its own emits
PresencePresence channels, ephemeralDedup by userId across a user’s tabs; admin filtering in the payload
Fan-out to non-DB eventsCan’t (nothing to change in Postgres)Free — the server broadcasts whatever it wants
Operational surfaceHostedOne Node process to run and monitor

The deciding factor was the fourth row. Presence — showing which teammates are currently looking at a project — isn’t a database row. Neither is "another user’s generation just entered a terminal status, please refetch your thread." Encoding those as Postgres rows just so a Realtime channel could carry them would have been architectural laundering; the events would arrive, but every consumer would have to reconstruct semantics that were only lossily written in the first place.

The two-service split also gives us a clean auth boundary that the browser can’t bypass — which brings us to the handshake.

The two-stage auth handshake

Every WebSocket collaboration system has to answer one question the moment a socket connects: who is this. We answer it by making the sync server ask the app.

Browser  ──join-room──►  Sync Server  ──/api/sync/validate──►  Next.js API
         ◄──presence:update──          ◄──{userId,email,name,isAdmin}──

When a client emits join-room with { projectId, tabId, authToken }, the sync server holds the socket, calls /api/sync/validate on the Next.js side, and receives back { userId, email, name, isAdmin }. Only then does it stamp that identity onto socket.data, join the socket to the project room, and broadcast presence:update to everyone in it.

The reason to route auth this way, rather than terminating it in the sync server directly, is that identity in our system is a Next.js concern: it involves NextAuth sessions, our users table, and platform-admin checks driven by the ADMIN_EMAILS env var. Duplicating any of that into the sync server would have created two sources of truth for who counts as an admin — the kind of divergence that shows up months later in exactly the wrong customer’s account.

The ProjectAuthService.validateProjectAccess method is the single decision-point for whether a user can enter a project room. It reads team membership, project_share rows, and the is_public flag, all under Supabase’s row-level security, and returns a shape the sync server can trust without re-checking anything.

The event schema — and the tab-id trick that makes it survive

Once a socket is in a room, our surface is small and domain-shaped:

  • files:created, files:updated, files:deleted — mirror the virtual file system across tabs
  • messages:created, messages:updated, messages:deleted — mirror the chat thread
  • presence:update — the current user list for the room
  • join-room, leave-room — client → server
  • project:emit — a generic passthrough for events we haven’t formalized yet

The interesting engineering is not the events. It’s the payload envelope. Every event carries a tabId, generated once per browser tab and stored in sessionStorage. The receiving client checks:

if (tabId === this.getTabId()) {
  return;
}

That single line prevents an entire class of nasty bugs. When you save a file, your own client emits files:updated. The sync server broadcasts it to everyone in the room, including you. Without the check, your tab would then re-apply the update to your own Redux store, which sometimes clobbers unsaved local state, sometimes fires a redundant thunk, and always makes the state graph harder to reason about.

Broadcasting to the sender and letting the sender drop its own echo is deliberate. The alternative — server-side socket.broadcast.to(room) that skips the sender — sounds simpler until you have two tabs open under the same user account. The tab you didn’t type in still needs to see the update. Per-tab dedup is the version that survives the "one power user with five tabs" case.

We use the same envelope shape for messages:updated, and it caught a bug worth mentioning. Two different code paths emit messages:updated: feedback ratings send { messageId, feedback }, and generation status transitions send { id, status }. The original handler only destructured messageId, which meant every status update from another collaborator’s generation was silently dropped. Reading both keys and folding them into targetId = messageId ?? id fixed it — and the incident is why every payload shape has a comment above it now.

Making AI generation a first-class multi-user event

The single hardest thing in this layer is that AI generation isn’t a keystroke. It’s a stream that produces many messages and many files over tens of seconds, and it terminates asynchronously with a status transition, not with a "done" character.

Two patterns make this bearable.

Silent refetch on terminal status. When a messages:updated event carries a status that isn’t streaming — meaning some other collaborator’s generation just finished — the client dispatches fetchMessages({ silent: true }). The word "silent" is doing real work: it refreshes the thread from the API without ever flipping the editor into the full-screen loading state. React reconciles the returned list by message id, and the mounted thread simply updates in place. Blanking the whole surface would have made every completed generation feel like a page reload for every viewer.

There’s one exception: if this tab is currently mid-generation itself (isAiRequestInProgress === true), the refetch is skipped. The streaming code already owns that message’s state, and racing a refetch against a live stream is how you get flickering half-written messages.

Compact-message full refresh. Long threads eventually get compacted server-side into a summary message — a common pattern in AI chat systems that keeps context windows manageable. When a messages:created event contains a message of type compact, the client doesn’t try to splice it in place; it fetches the whole thread again. That’s the honest thing to do, because a compact message means the earlier messages are semantically replaced, and any client-side patch would give the appearance of a valid thread while hiding the fact that the thread was rewritten.

const hasCompact = newMessages.some((m) => m.type === 'compact');
if (hasCompact) {
  this.dispatch(fetchMessages({ silent: true }) as any);
  return;
}

Both patterns are informed by a rule that keeps showing up in this codebase: the sync channel carries pointers, not truth. The database is truth. When the pointer says "something changed that I can’t fully describe in an event," the client goes back to the database. That’s slower per event, but it means our real-time layer never has to encode every possible server-side transformation into its wire format.

Presence, and why we dedup by userId

The presence avatars in the editor header are the most visible part of the sync layer, and they’re the one place where the multi-tab problem is impossible to hide.

The sync server broadcasts presence:update with an array of users, each stamped with { userId, email, name, isAdmin, tabId }. The client dedups by userId before dispatching:

const seen = new Set<string>();
const deduped = [];
for (const user of users) {
  if (!seen.has(user.userId)) {
    seen.add(user.userId);
    deduped.push({ userId: user.userId, email: user.email, name: user.name, isAdmin: user.isAdmin });
  }
}
this.dispatch(setActiveUsers(deduped));

Without dedup, a teammate with three tabs open would show up as three avatars — and worse, closing one tab would make one of "them" disappear, which is exactly the kind of thing that erodes trust in a UI that’s trying to signal presence.

The admin flag is filtered client-side: regular users don’t see platform admins in the avatar group. Support engineers open customer projects daily, and having those visits materialize as ghost avatars on the customer’s screen would be intrusive at best. Admins still see other admins, so support pairing works.

Avatar-group rendering itself is simple by comparison — max three Gravatar-driven avatars with -space-x-1.5 overlap, a +N overflow badge for the rest, tooltips resolving nameemail prefix — all reading straight from Redux via state.editor.activeUsers.

The team layer underneath: RLS, roles, sharing

Everything above rides on top of a team model that the sync server doesn’t need to know about, because Postgres already enforces it.

A team owns projects. team_users maps users to teams with a role column — owner, admin, member. Owners can’t be demoted. Admins can’t change each other’s roles. project_share and project_share_team_user let a project owner grant view or edit access to specific teammates. An is_public flag opens read-only access to anonymous visitors.

All of this is wired through row-level security. The sync server never queries the database directly — it asks the Next.js API, which runs every read through the requesting user’s Supabase client. If a user shouldn’t be able to open a project, validateProjectAccess says no, the sync server refuses to join them to the room, and no files:* events ever reach their browser. The security boundary is at Postgres, not at Socket.IO, which is where you want it.

Invitations are their own small subsystem. /api/team/add-member writes to invitations, sends a templated email through Resend, and short-circuits if the invitee is already a team member — a duplicate-invite check that lives in front of the shared TeamService.addMemberToTeam call because that shared helper sends the email before its own duplicate check would fire.

For a deeper walkthrough of the team model itself, see our post on how we built multi-team support.

What we deliberately didn’t build (yet)

Two omissions are worth naming, because they explain choices in the current design.

No live cursors on the canvas. A cursor is a high-frequency stream — 30–60 events per second per user — and it needs a conflict-free rendering model to be worth anything. We don’t have that yet, and adding it would triple the sync server’s load without solving a customer problem we hear. Comments carry spatial metadata (layerPath, coordinates) instead, which turns out to cover most of what teams actually want: "leave a note on this specific button."

No CRDT for concurrent editing of the same file. Yjs and Automerge are excellent, and neither is in this stack. Our editing model is intentionally serial per file — one AI generation at a time, one human edit at a time, with the Redux tab-id envelope preventing cross-tab conflicts on the same user. Two teammates who both try to type into the same file will fight, and we tell them so. A CRDT would let them not fight, at the cost of a whole new failure surface (merge outcomes users can’t predict). For an app builder where the AI is the primary author, that trade doesn’t pay off yet.

People Also Ask

How do you authenticate a WebSocket connection in a Next.js app?

Terminate authentication in Next.js, not in the WebSocket server. When the client connects, have it emit its identity via a short-lived token. The WebSocket server calls a Next.js REST endpoint (in our case /api/sync/validate) that runs the same NextAuth session logic as any other route, and returns a canonical user shape. The socket server stamps that identity onto the socket and never re-derives it. This keeps one source of truth for identity — which matters more than the extra hop.

What’s the difference between Supabase Realtime and a dedicated Socket.IO server?

Supabase Realtime broadcasts row-shaped events derived from Postgres changes, presence channels, and generic broadcast messages, with auth from the same JWT that gates your tables. A dedicated Socket.IO server lets you broadcast domain-shaped events that don’t correspond to any row change (like "a teammate finished a generation"), run custom auth handshakes, and inject presence semantics of your own. Realtime wins on operational simplicity; a dedicated server wins when your events are richer than your schema.

How do you prevent a client from receiving its own broadcasts?

Two options. Either don’t send them (server-side socket.broadcast.to(room) skips the sender), or send them and let the client filter. We send them and filter using a tabId stamped into every payload, checked as if (payload.tabId === this.getTabId()) return. Sending-and-filtering is more code but survives the case where one user has multiple tabs open under the same account — a case server-side skipping quietly gets wrong.

Closing

The sync layer that powers RapidNative’s real-time team collaboration isn’t architecturally exotic. It’s Socket.IO in a Node process, an authenticated handshake through the Next.js API, domain-shaped events with a tabId envelope, a Redux slice that treats events as pointers back to the database, and Postgres row-level security holding the whole thing together. Every piece is chosen because it makes the product cleaner — a chat-driven AI app builder where the AI is a first-class collaborator and human teammates need to see its work land in real time.

The real-time collaboration architecture pattern that’s carried the most weight for us is the smallest one: tabId in every payload, if (tabId === this.getTabId()) return in every handler. It’s three lines of code that stopped an entire class of state-management bugs and made the rest of the system safe to broadcast into.

If you’re building your own team-shaped AI product, try RapidNative for free — invite a teammate, open the same project in two tabs, and watch the presence avatars settle into place. That’s the sync layer working. Or read on for a deeper look at our streaming AI pipeline and the virtual file system it writes into.

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.