How We Built Multi-File Project Support into an AI App Builder
By Sanket Sahu
17th Jun 2026
Last updated: 17th Jun 2026
Every viral AI coding demo looks the same. A single text box, a single file, a single screen of generated code. It feels magical for about thirty seconds — and then you remember that real mobile apps are not a single file. A working Expo project has a package.json, a tsconfig.json, an app.json, an _layout.tsx, route files under app/, shared components, schema files, theme tokens, and maybe a few dozen screens. Multi-file AI code generation is the part of the stack that almost nobody talks about, because it's also the part where most AI builders quietly fall over.
When we built RapidNative — the AI-native mobile app builder that turns plain English into React Native and Expo projects — we knew the entire user experience would live or die based on how cleanly we modeled a multi-file project. This post walks through how it actually works: the database schema, the in-browser virtual file system, the LLM context construction, the streaming generation protocol, the post-processor pipeline, and the in-browser Metro bundler that compiles all of it for live preview.
A modern React Native project is dozens of files, not one — Photo by Daria Nepriakhina on Unsplash
The single-file demo trap
The first thing to internalize is that "AI generates code" and "AI generates a working project" are two different problems. A model that writes a clean Button.tsx in isolation has done about 5% of the work. The other 95% is everything around the file: knowing which path it should live at, what other files reference it, whether the import alias is registered in tsconfig.json, whether the bundler can resolve it, and whether your hot-reload pipeline can swap it in without nuking the running app.
A multi-file AI code generation system has to coordinate across at least six layers: persistence, in-memory state, LLM context, streaming output, post-processing, and runtime. We'll cover each one.
The data model: projects as keyed file maps
Every project in RapidNative is a row in a projects table — UUID, user_id, name, component base, type. The interesting table is files. Each row stores one file's content keyed by (project_id, file_path) with a unique constraint on the pair, plus columns for file_type (TypeScript, TSX, JSON, Markdown, CSS), mime_type, encoding, file_size, and timestamps.
We considered two designs early on: a hierarchical tree (parent_id pointers, like a filesystem inode table) or a flat key-value store where the path is the key. We went with the flat model and have not regretted it. Most operations are "give me every file in this project," "upsert this file," or "diff these N paths" — none of which benefit from tree traversal. A path string is cheap to index, cheap to compare, and cheap to send across the wire.
The schema is backed by Postgres on Supabase, with covering indexes for the two queries we run most: load-all-files-for-project, and load-files-by-type. Binary assets (images, fonts) get their own project_assets table that references Supabase Storage at projects/{projectId}/fs/{filePath} so we never bloat the database with PNGs.
The Virtual File System: in-browser truth at runtime
When a project loads in the editor, we fetch every file in a single API call against /api/user/projects/[projectId]/files and rehydrate them into a VirtualFileSystem (VFS) instance that lives entirely in the browser. The VFS is the runtime source of truth for everything the editor does: tabs, the Monaco editor, the bundler, the AI request payload, the export ZIP.
A few design choices that turned out to matter:
- Namespace per project. The VFS is keyed by
projectId, so a user with multiple projects open in different tabs gets isolated stores instead of one global blob. - An operation queue. Every write goes through a serialized queue. Without it, two near-simultaneous writes (say, an AI streaming patch landing during a user keystroke) can race and produce a partial file.
- A pub/sub watcher. Subscribers receive
VFSEventobjects (add,update,delete) and can react incrementally instead of re-reading the whole filesystem. The bundler worker is the biggest consumer.
This layer exists for a single reason: the AI pipeline, the editor, and the bundler all need a consistent view of "what files exist right now" without round-tripping to the database. By the time the bundler asks "what's in app/(tabs)/_layout.tsx?" the editor may have updated it three times in the last second. The VFS makes that lookup synchronous and free.
The VFS is what the editor, the bundler, and the AI all read from — Photo by Markus Spiske on Unsplash
Giving the LLM the right view of a multi-file project
Here is where most AI app builders get into trouble. The temptation is to stuff every file in the project into the prompt and let the model figure it out. This works for the first three screens and then collapses as soon as the project crosses about ten files. Context window cost explodes, latency tanks, and the model starts hallucinating relationships between files because it can't hold the whole graph in attention.
We use a different approach. The system prompt for the code generation step gets a file list — up to 100 paths, no content — as a directory listing. The model knows what files exist but not what they contain. When it actually needs to read a file, it calls a get_files_content tool during a separate context-gathering step. For pattern matching across the project, it has a batch_grep tool. This separation cuts the average request payload by roughly an order of magnitude and lets us run cheaper, faster models for context gathering than for code generation.
When the user has a file currently open in the editor, we inject that file's content directly into the prompt along with two project-wide reference files (layout.md and theme.md). Then we run a one-hop import resolution: read the source file, parse the imports, and pull in any directly referenced local files — but no further. This is the most important constraint in the whole pipeline.
// Roughly: read the active file, resolve aliases, pull one hop of local imports
function resolveDirectLocalImports(
sourcePath: string,
sourceContent: string,
projectFiles: Record<string, string>,
aliasMap: AliasMap
): string[]
The alias map comes from each project's rapidnative.json, which mirrors tsconfig.json's paths field so @/components/Button resolves to the actual filesystem path. We chose one-hop resolution after watching multi-hop versions either explode the context window or, worse, follow an import chain into a third-party package and try to "fix" it.
Streaming structured output across multiple files
The generation endpoint streams over Server-Sent Events. That part is unremarkable — what's interesting is how we structure the events so the client can update individual files in the VFS as they arrive instead of waiting for the whole response.
The stream is a sequence of typed events: start, text, tool-call, tool-result, and the one that matters here, codeproject. A codeproject event carries a JSON payload that looks roughly like:
{
"type": "codeproject",
"files": [
{ "filename": "app/index.tsx", "content": "..." },
{ "filename": "components/Button.tsx", "content": "..." },
{ "filename": "package.json", "content": "..." }
]
}
The trick is that we don't make the model invent JSON structure during streaming — it writes markdown-style fenced code blocks with file-path headers, and a parser on the server splits them into discrete codeproject events as each block closes. This is more robust to partial output than asking the model to maintain JSON across a long response.
Above the generation step, the whole request flows through a four-stage pipeline: context gathering (cheap model with tools), auth/screen-limit detection (parsing a NEW_SCREEN: yes/no line that the context model emits), deterministic schema generation (database tables and auth pages that we don't trust the model to do), and finally code generation (the main model, no tools, just write the files). Each stage hands the next stage a smaller, more focused prompt.
Post-processing: making AI output actually compile
This is the section every AI app builder skips, and it is the one that does the most work. When a model finishes streaming, the files are almost right. Almost. There's a stray markdown fence at the top. A useState hook is referenced but not imported. Two import { View } lines exist because the model wrote one for View and another for View, Text. A package.json is missing a comma. None of these are model failures — they are statistical artifacts of how language models produce structured output.
The post-processor pipeline runs every generated file through an ordered chain:
- content-cleaner — strip residual markdown fences, normalize whitespace.
- package-json-guard — validate JSON, repair common breakages.
- duplicate-var-fixer — detect and merge duplicate
constdeclarations. - jsx-fixer — scan for JSX tags and hooks that are used but not imported, add the imports.
- import-dedup — collapse multiple imports from the same module into one.
- import-fixer — resolve
@/-prefixed paths against the project alias map. - schema-validator — check generated database schemas against project conventions.
- syntax-validator — last-pass parse check.
Each processor takes a FileEntry[] and can mutate content in place. A failure in one stage doesn't abort the pipeline — we log it and continue, because half-broken output is better than no output. A streaming-safe subset (the cheap ones — content-cleaner, jsx-fixer for missing React imports) runs during generation so the live preview can boot before the full pipeline finishes.
If you have ever wondered why some AI mobile app builders feel like "AI guessed at code" while others feel like "this just works," the difference is almost always whether they have a post-processor pipeline.
Most of the "AI-generated code works" magic is non-AI plumbing — Photo by Luca Bravo on Unsplash
Bundling dozens of files in the browser for live preview
Once files are in the VFS and post-processed, we need to actually run them. RapidNative's real-time React Native preview is a browser-resident port of Metro, Expo's bundler, running entirely in a Web Worker. We call our integration almostmetro because that's exactly what it is: most of Metro's resolution and transformation behavior, none of the filesystem and HTTP server pieces, adapted to read from the VFS instead of disk.
The flow:
- The VFS is converted into a
FileMap— an absolute-path-keyed dictionary of{ content, isExternal }entries. Hidden directories (.expo/,.git/), tooling configs (eslint, babel, metro, tailwind), and binary CSS files get filtered or shimmed to empty modules. - The bundler worker boots with
watchStart(fileMap)and emits awatch-readyevent with a JS bundle. - The host page wraps the bundle in an HTML scaffold that includes the Tailwind config and source maps, creates a Blob URL, and points the preview iframe at it.
- As the user (or the AI) edits files, the VFS watcher pushes
sendUpdate(contentChanges)messages to the worker, which produces either anhmr-update(hot-module replacement) or awatch-rebuild(full rebuild).
The error-handling story matters too. A typo in one file should not bring down the entire preview. The bundler maintains a fileErrors map; when a file fails to parse or transform, we substitute a BrokenComponentStub that renders an inline error inside the offending screen and lets every other screen keep running. A syncFileErrors() step diffs the error set on each rebuild and emits Redux events (build_error_received, build_error_resolved) so the editor can show a red dot on the broken file without forcing a global error overlay.
Respecting Expo Router conventions automatically
A meaningful chunk of the system's intelligence is hard-coded knowledge about Expo Router — Expo's file-based router. The bundler and the AI both share a small expo-router-filters module that converts file paths to route paths:
app/(tabs)/index.tsx→/app/(tabs)/profile.tsx→/profileapp/courses/[id].tsx→/courses/:id
Route groups in parentheses are organizational and drop out of the URL. _layout.tsx files are layouts, not routes. Files under _private/ are excluded. Dynamic routes get default seed values from a dynamicRouteDefaults object in rapidnative.json — so when the AI generates a courses/[id].tsx screen, it also generates a default { "id": "1" } so the preview has something to render.
This is one of the places where being React Native-specific has paid off compared to general-purpose code generators. We know exactly what conventions to enforce, what files are layouts vs. routes, and how to wire seed data so the preview is always renderable.
Exporting a real downloadable project
When a user hits export, the multi-file architecture pays off again. The /api/user/projects/[projectId]/download route reads the VFS payload, iterates every entry, and writes them into a ZIP using adm-zip. External assets get fetched in parallel from Supabase Storage and written as binary buffers. A linear-gradient migration step rewrites react-native-linear-gradient imports for newer Expo versions before zipping. The result is a real, runnable Expo project the user can clone, npm install, and ship to the App Store — covered in more depth in our export pipeline post.
Constraints we learned to live with
A few honest numbers from the system:
- 100 files is our soft cap when sending the file listing to the LLM. Beyond that, we summarize or filter by relevance.
- 5 routable screens is the free-tier ceiling — enforced by parsing a
NEW_SCREEN: yes/noline from the context model before we pay for generation. - One-hop import resolution is non-negotiable. Multi-hop walks always either run out of context or chase imports into third-party packages.
- 6000 characters is the soft cap on a single user prompt. Past that, we truncate.
- No real-time multi-user editing yet. The sync layer notifies clients about file updates, but we haven't shipped Liveblocks-style cursors. It's the most asked-for thing on our roadmap.
These limits are not aspirational targets we're trying to remove. They are the boundaries that let the system stay fast and reliable for the kinds of apps people actually build on it — onboarding flows, dashboards, marketplaces, fitness trackers, internal tools.
Try it
If you want to see this whole pipeline in action, the fastest way is to open the editor and describe an app. Start from a text prompt, from a whiteboard sketch, from a PRD document, or from a screenshot of an app you like. Within a few minutes you will be looking at a multi-file Expo project with a live preview running in your browser — and now you know what's actually happening underneath. Full pricing and team plans are on the pricing page.
Multi-file AI code generation is not the loud part of the AI app builder pitch. It is, however, the part that decides whether the thing you generate is a working app or a screenshot of an app. Get the data model, the VFS, the streaming protocol, the post-processors, and the bundler right, and the AI part starts looking a lot less magical — and a lot more like real software.
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.