The Virtual File System Behind Multi-File AI Code Generation
(155 chars):
By Rishav
20th Aug 2026
Last updated: 20th Aug 2026
Ask any large language model to modify one file and it will generally do a passable job. Ask it to modify twelve files — a screen, a route, three shared components, a Redux slice, a schema migration, an API handler, and the type files that feed all of them — and the entire conversation quietly falls apart. Files reference stale exports. Route registries drift. The preview rebuilds constantly, or worse, doesn't rebuild at all. This is the problem every serious AI app builder eventually collides with, and it is not solved by better prompting or a bigger context window.
At RapidNative, we generate full React Native and Expo apps from a single prompt. That means multi-file AI code generation is not a feature — it is the substrate everything else runs on. This post is a technical walk through the architecture that makes it work: the dual-layer virtual file system, the coordination pattern between the AI stream and the live preview, and the two or three decisions that quietly cost us the most time to get right.
A modern app is dozens of interconnected files — coordinating AI edits across all of them is the real problem. Photo by Fotis Fotopoulos on Unsplash
Why single-file AI generation doesn't scale to real apps
The naive way to add "AI edits" to a code editor is to send the whole file to a model, ask for a rewrite, and replace the buffer with the response. This works fine for a snippet or a component. It stops working the moment your project has route conventions, a state container, a database schema, and a preview surface that expects a specific bundle shape.
A real React Native app in our system is usually somewhere between 30 and 120 files across half a dozen directories. When a user says "add a favourites screen and a way to bookmark items from the detail view," the model needs to:
- Create
app/favourites.tsx(a new Expo Router route) - Edit
app/detail/[id].tsx(add a bookmark button) - Edit or create
store/slices/bookmarksSlice.ts - Add a migration under
supabase/migrations/ - Update
src/db/types.tsto reflect the new table - Register the route in whatever tab bar or drawer is showing
Six files, minimum, and every one of them has to reach the running preview coherently or the app crashes on reload. A single-file mental model produces exactly the wrong architecture for this. What we needed was a virtual file system that could accept a stream of writes from the AI, keep three consumers in sync (persistence, preview bundler, editor UI), and coalesce noisy intermediate states into stable checkpoints.
The data model: files as first-class rows
At the storage layer, every project file is a row in a single Postgres table. The schema is boring on purpose:
| Column | Type | Purpose |
|---|---|---|
id | UUID | Primary key |
project_id | UUID | Foreign key to projects |
file_path | VARCHAR(500) | Unique identifier within a project |
mime_type | VARCHAR(100) | text/typescript-jsx, application/json… |
file_type | ENUM | 24 categories (tsx, sql, json, markdown…) |
encoding | ENUM | utf-8, ascii, binary, base64 |
content | TEXT | Inline for text; empty for external assets |
file_size | INTEGER | Reported size in bytes |
The important constraint is UNIQUE (project_id, file_path). Everything else — the file tree, the AI's file-read tool, the diffing UI, the export pipeline — treats file_path as the primary way to talk about a file. Rename is UPDATE, not DELETE + INSERT. This matters for binary assets whose bytes we never read: a text round-trip would corrupt them, so we preserve the row identity through moves.
Binary assets have their own table (project_assets) and live in Supabase Storage at /projects/{projectId}/fs/{path}. The files row still exists but is marked is_external=true with empty content, and the VFS fetches bytes on demand. This lets a user upload a 3 MB icon without dragging its base64 encoding through every AI turn.
The dual-layer virtual file system
Here is the piece that actually earns the "architecture" label: files live in two coordinated places on the client.
Layer one — Redux. The editor state contains a plain files: { [path: string]: File } map. This is the single source of truth for the UI. When the code panel needs to render, when the file tree needs to build, when the AI tool read_file needs a buffer, they all read from Redux. Each File object carries path, content, mimeType, isExternal, and — the important one — an optional batchId that tags files written together in one AI turn.
Layer two — the Lifo sandbox VFS. RapidNative runs a browser-hosted execution environment (@lifo-sh/core) that mounts a real filesystem at /home/user/app/. The bundler, the API server, and any dev-time watchers inside the sandbox see files through this VFS, not Redux. When Redux updates, the file API layer writes the same content into Lifo. When the sandbox needs to read a file (e.g. Metro building a module), it reads from Lifo.
Two filesystems in the browser, kept coherent by an event bus. Photo by NASA on Unsplash
We resisted this for months. One VFS was simpler, obviously. But every attempt to unify collapsed under the reality that Redux needs synchronous reads for React re-renders, and Lifo needs an actual POSIX-shaped filesystem for the bundler to work. So we settled on a dual-write pattern: every file operation goes through a small API surface (addFile, updateFile, deleteFile) that writes to Lifo, updates Redux, and hits Supabase — in that order, with each step optional so tests can stub any of them.
The wrapper emits change events shaped like { type, path, content, mimeType, source, batchId }. Two subscribers matter: the browser-metro bundler (rebuilds and pushes HMR), and the editor UI (updates the file tree, re-renders the code panel). The source field lets us distinguish AI writes from human edits, which turns out to matter for both undo behaviour and for suppressing spurious "you've edited this file" indicators mid-stream.
Streaming multi-file edits from the AI
Most AI code editors treat generation as a single blocking request-response. That is fine for chat. It is unacceptable for a preview: users watch the app being built, and a five-second pause in the middle of a twelve-file edit feels like the tool has hung.
Our generation pipeline is a two-step process. A fast context-gathering model uses tools (read_file, list_files, resolve_local_imports, image analysis) to assemble what the main model needs to see. Then the main generator — currently Claude Opus for hard turns — produces code as a stream, with tool calls that emit files as their output.
The key abstraction is what we call file-producing tools. Tools whose output includes files (like db_migration_new and any bash-shaped write) all follow the same output shape:
{
files: [
{ path: 'app/favourites.tsx', content: '...' },
{ path: 'store/slices/bookmarksSlice.ts', content: '...' }
]
}
A single utility, filesFromToolOutput(), extracts these objects wherever they appear. It is consumed by three code paths — the live stream handler, the replay path (for undo/history), and the predicate that decides whether a turn changed the filesystem. Keeping all three behind one function is what stops the classic "the live stream shows a file but undo doesn't know it exists" class of bug.
Every file the AI emits gets stamped with the same batchId (a UUID minted at the start of the turn). Undo reverses everything with that ID atomically. The preview treats them as a single coherent update. The persistence layer batches them into one database roundtrip. One identifier propagates all the way down.
The AI's output streams file-by-file into the VFS; each file appears in the preview as soon as its content is complete. Photo by Chris Ried on Unsplash
Coordination: write, watch, rebuild, don't overreact
The naive implementation of "AI writes file, preview rebuilds" produces a preview that flickers and reloads constantly. We learned this the hard way. A twelve-file edit was triggering twelve Metro rebuilds and, worse, two full document reloads because two of those files were new Expo Router routes. Users saw the app blink to blank three times in fifteen seconds.
The fix was a set of coordination rules the VFS wrapper now enforces:
Rebuild coalescing. browser-metro (a Metro fork adapted for the browser sandbox) watches the VFS. When it detects a change, it waits ~50ms for the burst to settle before starting a rebuild. Twelve writes in the same batch produce one bundle rebuild, not twelve. HMR patches for existing modules stream to the iframe without disturbing the app's route.
Route context regeneration. Expo Router uses a synthetic module — __expo_ctx.js — that maps route file paths to their require() calls. Adding a new route file is not enough on its own; the running app has already loaded the old context and there is no HMR accept boundary for a module nothing imports. So the VFS regenerates __expo_ctx.js after each batch and, if new routes were added, requests exactly one full document reload once the batch settles. Repeated mid-stream reloads are destructive — each one restarts the app and discards partial render — so we count them and cap.
API server debouncing. For fullstack projects with an in-sandbox Express server (api/*.ts), file writes to api/ schedule a debounced restart of that server. Multiple API edits in a two-second window coalesce into one restart. The frontend keeps hitting a stable localhost:3001 throughout the burst; only requests that arrive during the actual restart briefly fail.
API deploy, not just reload. For fullstack-supabase projects, a new migration file needs to be applied to the in-browser Postgres engine (@tinbase/pg-mem) before any query that references it can run. The VFS layer detects supabase/migrations/*.sql writes and pipes them through a validator that applies the migration on top of existing schema before accepting the file. If the migration is broken, the AI sees the failure and can revise; the preview never sees a half-applied schema.
The expo-router complication
Expo Router is a filesystem-based router: files under app/**/*.tsx become routes. That sounds simple until you consider what "add a file" means when the app is already running.
Three cases have to be handled distinctly. Editing an existing route file is easy — HMR patches the module and React swaps it in. Deleting a route file requires unregistering it from the context. Adding a new route file is the hardest case: the module didn't exist when the running bundle loaded, so re-requiring won't help; the app has to reload for its route registry to include the new file.
Detecting Expo Router vs. plain React Native is a two-check convention: either package.json main equals expo-router/entry, or an app/_layout.tsx file exists. That gate matters — we don't want to regenerate a context file or trigger a reload for a project that doesn't use Expo Router at all.
Dynamic segments (app/detail/[id].tsx) get a further wrinkle. The editor lets users preview these screens directly, so rapidnative.json supports a dynamicRouteDefaults map:
{
"dynamicRouteDefaults": {
"detail/[id].tsx": "/detail/1"
}
}
The preview mounts on the resolved route (/detail/1), and the AI is told about this default when it edits the file, so the placeholder data it generates matches the URL the preview will actually visit.
The preview iframe as a consumer, not a coupled component
The preview iframe deliberately knows nothing about Redux, nothing about the AI stream, nothing about the file tree. It receives one input — a bundled URL from browser-metro — and one control channel — a set of postMessage events for navigate and reload. That decoupling is what lets us confidently point the same preview at a live-editing session, a captured replay, or a debug harness that skips the AI entirely.
That harness is worth mentioning because it enforces the architecture in a concrete way. In development we have a /project/blank page that reuses the real project route with no database, no auth, no AI, and replays a captured generation verbatim at its measured cadence — around 477 characters per second. Because it uses the same routing, same Redux store, and same VFS wrapper as production, if the harness passes and a real project fails, the bug is definitionally in something outside the write-to-render path. That single inference has located every hard bug in this pipeline for the last year.
The persistence pattern that was almost the wrong one
The last piece worth calling out is how VFS writes reach Supabase. Our first version wrote to the database on every keystroke. This was fine for a human typing at 5 characters per second and catastrophic for an AI streaming at 500. So the network layer now debounces writes per file with a short trailing window, batches AI-generated files by batchId into a single insert-or-update, and only ever writes through a small updateFile/deleteFile API rather than free-form SQL.
The rule we now enforce in reviews: no code outside api-operations.ts may write to a project file. This is the kind of constraint that feels bureaucratic until the first time someone adds a "helpful" utility that bypasses the batching layer and takes the preview offline for everyone using their team. Centralising the write path was the single change that stabilised the whole pipeline.
What we'd do differently
Three things, in decreasing order of how much they would have saved us:
Design the VFS event schema before the third consumer. We had Redux and Lifo synced for months before browser-metro joined as a third subscriber, and the event shape at that point still assumed two consumers. Retrofitting source and batchId was more work than defining them upfront.
Ship batchId on day one. Undo, "recent changes" summaries, cost accounting per turn, and the AI's own ability to say "here is what I just did" all sit on top of that field. Every one of those features arrived later than it should have because the initial data model didn't carry a batch identifier.
Assume the preview can be wrong. Our earliest architecture treated the running iframe as authoritative — "if the preview looks right, the state is right." It is not. The preview is a consumer of the VFS, downstream of Redux and Lifo, and can drift in surprising ways when tab focus shifts, sandbox workers restart, or the bundler swallows an error. Every disagreement between VFS state and preview state is a bug in the coordination layer, not a UI glitch to be styled around.
What multi-file support actually unlocks
The reason this architecture matters is not that it lets the AI edit more files. It is that once multi-file editing is coherent, entirely different feature classes become possible: real database migrations that stay in sync with generated types; API routes that deploy alongside the screens that call them; refactors that rename an export and update every importer; and the instant preview that would otherwise be impossible if every AI turn dropped a rebuild grenade in the middle of the running app.
If you want to see it working end-to-end — the streaming, the coordination, the multi-file edits landing in a live preview — try RapidNative for free. Or if the deeper story is what you're after, our writeups on how we stream AI-generated components in real time and why we run a 4-step LLM pipeline fill in the surrounding context. The single-file era of AI code generation is ending. The interesting engineering starts when a model has to change six files coherently and the preview has to still be running when it finishes.
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.