How We Built Real-Time Collaboration for Team App Building
By Riya
13th Jul 2026
Last updated: 13th Jul 2026
The first time two people opened the same RapidNative project at the same time, we had a problem. One person typed a prompt, the other refreshed the page — and their changes disappeared. The AI had generated screens for both users independently. Neither knew the other was there. It was single-player software pretending to be a team tool.
Fixing that took more than turning on a "realtime" flag. It meant rethinking how state moved between the browser tabs, the Next.js API routes, and Postgres — and picking, out of a half-dozen plausible architectures, the one that would still be sane in eighteen months. This post walks through the actual stack we shipped: a Socket.IO sync server, a Redux slice for presence, and a set of server-side sendSyncNotification() calls that fan writes out to every teammate in the room. We'll cover what real-time collaboration actually means inside an AI mobile app builder, the trade-offs we made against Supabase Realtime and CRDTs, and — just as important — the things we deliberately chose not to build.
What real-time collaboration means in an app builder
Most collab systems collapse into one of three shapes. There's the Google Docs shape, where the atomic unit is a keystroke and you need CRDTs or operational transforms to merge concurrent edits without corruption. There's the Figma shape, where you have shared canvas state and live cursors and every mouse position is broadcast at 60fps. And there's the Notion shape, where the atomic unit is a block, edits happen in bursts, and last-writer-wins is fine most of the time because the granularity is coarse.
RapidNative is closer to the Notion shape than the Google Docs shape — but with an important twist. In a normal collab tool, humans are the source of edits. In RapidNative, humans send prompts and the AI generates entire React Native components at once. A single prompt might touch six files. There is no "typing" happening in the classical sense; there are large, atomic file writes coming from a language model, punctuated by human clicks in the visual editor.
That reshapes the whole problem. We don't need character-level merge resolution. We need:
- Presence: who else is in this project right now
- Message sync: when a teammate sends a prompt to the AI, everyone sees the request and response appear
- File sync: when the AI (or a human edit) writes a file, everyone's preview updates
- Access control: a project can be private, team-visible, or public — and roles matter
That's a much smaller problem than "build Google Docs for code." Which is why we ended up with a very different stack than you might expect.
Why we didn't use Supabase Realtime
RapidNative runs on Supabase for auth and Postgres, and Supabase ships a Realtime engine — a Phoenix-based server that broadcasts postgres_changes events over WebSockets whenever a row is inserted, updated, or deleted. On paper, it should have been the obvious choice. We already had @supabase/supabase-js and even @supabase/phoenix in package.json (you can see them in the actual dependency list, versions ^2.57.4 and ^0.4.0). We wouldn't have needed to run a separate service.
We ran a prototype. It didn't survive first contact with the AI generation pipeline. Two problems killed it.
Problem 1: fan-out semantics. When the AI writes six files in response to a prompt, that's six INSERT statements into project_files. With Supabase Realtime, every subscribed client receives six separate postgres_changes events, in insertion order, each triggering a re-render in the editor. There's no way to say "these six writes are one logical event, please batch them." The preview would flicker as files arrived one at a time.
Problem 2: ephemeral state has no home. Postgres is a terrible place to store "which tabs are open right now." Presence in Supabase Realtime is a separate channel API with its own semantics, layered on top of the postgres_changes subscription. We'd have needed two subscription models — one for durable state, one for ephemeral — and they don't share ordering guarantees. And presence needed something Realtime didn't give us for free: a tabId distinct from userId, so we could show one avatar per human even when a user had the project open in three tabs.
We looked at those two problems and decided we wanted a purpose-built sync layer that treated a project as a room, not a database subscription. That layer would broadcast semantically meaningful events (files:created, messages:updated, presence:update) with a shape we controlled, in an order we controlled, batched however we wanted.
That layer is a Socket.IO server. It runs as a separate process from the Next.js app. And the Next.js app talks to it over HTTP.
The Socket.IO sync server, in one page
Here's the architecture in shape. Three participants:
- The browser — running the RapidNative editor. Holds Redux state including
messages,activeUsers, andprojectDetails. Connects to the sync server viasocket.io-client(v4.8.1). - The Next.js app — handles auth, API routes, AI streaming, and Postgres writes. Never receives WebSocket connections directly.
- The sync server — an external process at
NEXT_PUBLIC_SYNC_SERVER_URL(defaulthttp://localhost:3002in dev). Maintains rooms keyed byprojectId. Broadcasts events to every socket in a room.
The browser joins a room on project load, listens for events, and dispatches them into Redux. The Next.js app, whenever it writes to Postgres, POSTs a notification to the sync server telling it what changed and which room to broadcast to. That's it. The sync server never touches Postgres directly. It's a stateless broadcast fabric.
The client-side glue lives in src/services/sync-service.ts — a single 393-line module that wraps a Socket.IO connection and turns incoming events into Redux dispatches. Here's the connection shape it uses:
socket = io(SYNC_SERVER_URL, {
transports: ['polling', 'websocket'],
reconnectionAttempts: 5,
reconnectionDelay: 1000,
});
Note the transport order. Socket.IO defaults to ['polling', 'websocket'] and upgrades once the WebSocket handshake succeeds. We kept that default because our sync server sits behind a proxy that occasionally drops long-lived WebSocket connections on cold starts — starting with polling gives us a working connection immediately and lets the upgrade happen when it can. It's slower on the first message but never stuck.
When the editor mounts, it calls:
syncService.joinProject(projectId, authToken);
// internally emits: socket.emit('join-room', { projectId, tabId, authToken })
The tabId is generated per browser tab. It's how we deduplicate presence — more on that in a moment. The authToken is a signed value from the NextAuth session; the sync server verifies it before letting the socket into the room, so you can't just guess a projectId and eavesdrop.
The four things we sync
We ended up with four event families. Each corresponds to a different concern in the product.
1. Presence
The presence:update event carries the full list of users currently in the room:
{
users: [
{ userId, email, name?, isAdmin?, tabId }
]
}
The client-side sync-service deduplicates the array by userId before dispatching — because a single user can hold multiple tabIds, and we want to show one avatar per human, not one per tab. The dispatch lands in the Redux editor slice, in a field called activeUsers:
activeUsers: {
userId: string;
email: string;
name?: string;
isAdmin?: boolean;
}[] = [];
An ActiveUsersAvatarGroup component (in src/modules/studio/components/custom/editor/EditorHeader.tsx) reads that slice, filters out the current user, filters out admin users if the viewer is non-admin, and renders up to three Gravatar avatars with a +N more badge for overflow. It also honors project sharing rules — if a user doesn't have owner, edit, or view access, the group doesn't render at all.
Presence sounds like a small thing. It is not. The moment you see a teammate's avatar appear in the header, the product stops feeling like a spreadsheet you're editing alone. It starts feeling like a room you're in together. That psychological shift is worth every line of code it took.
2. Messages
The chat panel is the primary interface for talking to the AI. When one teammate sends a prompt, the others should see the request and the streamed assistant reply as it happens.
Messages live in Postgres in a messages table with columns id, project_id, content, type ('user' | 'assistant' | 'system'), image_url, and timestamps. The AI generation route (POST /api/user/ai/generate-v2) streams the assistant response back to the initiating tab over Server-Sent Events. When the stream completes, the route writes the final message to Postgres and fires:
notifyCreated('messages', { projectId }, { messages: [userMsg, assistantMsg] });
That's a helper in src/modules/api/utils/sync-server.ts — a fire-and-forget POST to the sync server with an authenticated bearer token. The sync server broadcasts a messages:created event to every socket in the project's room. Every other tab dispatches setMessages() to append the new messages to Redux, and the chat panel re-renders.
There's an honest limitation here worth naming. The stream itself isn't shared. Only the tab that initiated the prompt sees tokens arrive in real time. Everyone else sees the completed messages appear after the stream closes. Streaming an SSE response to multiple recipients in different processes is a solvable problem — we'd need to add a pub/sub relay for the token chunks — but the value isn't obvious. When you're watching a teammate work, you almost always want the finished answer, not a play-by-play of the model deliberating. We may revisit it. We haven't yet.
3. Files
The AI writes files. Humans occasionally edit files. Both changes should propagate to every open preview.
The event shape is files:created, files:updated, files:deleted, with payload { filePath, content, mimeType, tabId, projectId }. The tabId is critical: the tab that originated the write already updated its own local state optimistically, so it ignores the echo. Every other tab treats it as authoritative and updates its virtual file system.
That virtual file system lives in src/modules/worker/lib/virtual-file-system/. It's an in-memory representation of the project, with a watch() API that fires callbacks whenever files change. When a files:updated event arrives, the sync service writes the new content into the VFS; the VFS notifies its watchers; the preview iframe hot-reloads.
Coarse-grained, whole-file sync means no operational transforms and no merge conflict logic. If two teammates edit the same file at the same moment, the last write wins. In practice this almost never happens — the AI is the dominant writer, and humans in a team are usually working on different screens. But we're aware of it and we've mostly designed around it: prompt-driven edits go through a queue that serializes writes on the server side.
4. Comments
Comments are polymorphic — you can attach them to a project, a file, a layer (a specific element in a screen), a message, or another comment. The schema lives in 20250916111647_add_comments_table.sql:
CREATE TABLE public.comments (
id UUID PRIMARY KEY,
user_id UUID NOT NULL,
comment TEXT,
attachment TEXT,
commentable_type TEXT NOT NULL, -- 'project' | 'file' | 'layer' | 'message' | 'comment'
commentable_id UUID NOT NULL,
parent_id UUID, -- NULL for top-level, FK for replies
source_data JSONB, -- layer coordinates, selection ranges
deleted_at TIMESTAMP -- soft delete
);
Comments are the one place where we intentionally didn't wire up real-time sync. They're fetched on demand and refreshed on navigation. Two reasons. First, the natural cadence of feedback is much slower than the natural cadence of code generation, and pushing live comments felt more like noise than signal. Second, comment threads have edit and reply and soft-delete semantics that are annoying to keep consistent across clients without a proper reducer. We decided the ROI was low and shipped without it. If usage patterns change, adding it is one notifyCreated('comments', ...) call away.
The Redux–Socket.IO handshake
The link between Socket.IO events and the editor's UI is a single Redux slice. Every socket event ends in a dispatch(). That's important — it means the rest of the editor never has to know that sync exists. Components read from the store; the sync layer writes to the store; the two never touch each other directly.
The plumbing looks like this. sync-service.ts accepts a Redux store at initialization and holds a reference. Each socket event handler is thin — parse the payload, dispatch to the right reducer, done:
socket.on('presence:update', (payload) => {
const dedup = deduplicateByUserId(payload.users);
store.dispatch(setActiveUsers(dedup));
});
socket.on('messages:created', (payload) => {
const current = store.getState().editor.messages;
store.dispatch(setMessages([...current, ...payload.messages]));
});
There are no thunks in the sync layer, no async logic. Everything is a straight event → dispatch line. If a bug shows up in what teammates see, the fix is almost always in a reducer, not in the network layer. That separation has been one of the highest-leverage decisions we made — it's kept the sync service tiny and easy to test.
Server-side, the mirror image is sendSyncNotification():
export async function sendSyncNotification(
resource, // 'files' | 'messages' | 'comments' | 'presence' | ...
action, // 'created' | 'updated' | 'deleted'
{ projectId },
data,
options?
) {
await fetch(`${SYNC_SERVER_URL}/sync`, {
method: 'POST',
headers: {
Authorization: `Bearer ${TRUSTED_SYNC_SERVER_TOKEN}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
projectId,
event: `${resource}:${action}`,
tabId: data.tabId,
data: { ...rest, timestamp: Date.now() },
}),
});
}
Every API route that writes state calls one of the notifyCreated / notifyUpdated / notifyDeleted wrappers. The route doesn't wait for the sync server to respond — it's fire-and-forget by default. If the sync server is down, the write still succeeds and the client will re-fetch on next navigation. Sync is a cache freshness concern, not a durability concern. That inversion is why we can run the sync server without redundancy and still sleep at night.
What we chose not to build
Every architecture is defined as much by its omissions as by its inclusions. A few of ours:
No CRDTs, no operational transforms. We considered Yjs seriously. It's an excellent library. But the atomic unit of edit in RapidNative is a file, not a character, and CRDT overhead pays for itself only when concurrent character-level edits are the norm. Our writes are dominated by the AI generating whole files at once. Yjs would have been a lot of complexity for cases that almost never happen.
No live cursors. Cursor positions would require sending mouse movement events at 30–60 Hz per user per room. That's a lot of network. And in an AI-driven app builder, the value of watching a teammate's cursor is much lower than in Figma — most edits happen through prompts, not direct manipulation. We may add cursors for the visual layer editor eventually; for now, presence avatars are enough.
No shared AI streaming. As mentioned above, only the initiating tab sees the SSE stream. Everyone else sees the finished message. We'd need a pub/sub relay to fan the token stream out to other rooms members. Solvable, not shipped.
No offline mode. We rely on being online for both AI generation and sync. Building offline-first would mean a service worker plus local persistence plus conflict resolution on reconnect — a project bigger than the current one.
You can look at that list as a list of missing features, or you can look at it as a list of complexity we chose not to pay for. Either framing is fair. We picked the second one.
The team system that sits underneath
None of this works without a team system. RapidNative's team model is straightforward: users belong to teams via a team_users join table; each team can own projects; each project has a project_share row that controls team-wide access (restricted | view | edit) and a is_public flag. A user's current team is tracked in users.selected_team_id, but only as a fallback for initial page load — the source of truth at runtime is the Redux store and session storage, because team switching is a client-side action.
When someone joins a team, the invite comes in through POST /api/team/add-member in src/app/api/team/add-member/route.ts. If the invitee already has an account, they're added directly to team_users; if not, we create a user (isBetaUser: true) and send an invitation email. The next time they open a project, the sync service will honor their team access level before letting them into the room.
If you're curious about how the team-based pricing tiers interact with this — team plans get shared credits, and the same real-time sync layer is what makes shared credit balances update everywhere at once.
What we'd do differently
If we were starting over today, one thing would change: we'd put the sync server behind a proper message broker (probably NATS or Redis Streams) from day one, rather than as a monolithic Socket.IO process. Not because we've outgrown it — we haven't — but because the moment we want to horizontally scale the sync layer, we'll need to solve room affinity, and doing that with a broker is straightforward while doing it with a fleet of Socket.IO nodes and sticky sessions is annoying.
The rest of the architecture has held up well. Redux as the single source of truth on the client. sendSyncNotification() as the single fan-out point on the server. tabId for deduplication. Semantic events over postgres_changes. Coarse-grained file sync instead of CRDTs. All of those still feel right.
Try it with your team
If you're curious what this feels like in practice, open a RapidNative project with a teammate and watch each other's avatars appear. Send a prompt from one browser and see it show up in the other. That whole experience — the avatars, the streaming chat, the preview updating in every open tab — is the sum of everything above.
You can also read more about the underlying pieces: how RapidNative streams AI-generated components in real time and how the collaborative app builder handles permissions. Or start a project and see how far you can get with a team before you hit the AI. That's the fun part.
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.