Inside RapidNative's React Native Asset Pipeline: From Upload to Export
(159 chars):
By Riya
31st Aug 2026
Last updated: 31st Aug 2026
Every AI-generated mobile app has a hidden second product living inside it: the asset pipeline. Users don't think about it — they drop a logo into the chat, paste a screenshot, request an app icon, and expect the image to appear in the preview, survive an export, and eventually ship inside an IPA. Under the hood, that expectation touches a browser upload, a Supabase Storage bucket, a Postgres row, a Metro bundler running in an iframe, an LLM tool call, and finally a downloadable ZIP.
This is a walk through the actual react native asset management pipeline behind RapidNative — table names, bucket paths, size limits, and the decisions we made (some of them uncomfortable) to keep it fast and predictable across every AI-generated Expo app.
The problem: assets are not just files
If you're writing an Expo app by hand, asset management is straightforward. You drop logo.png into assets/, require('./assets/logo.png'), and Metro handles bundling, screen-density variants, and caching. Done.
An AI app builder doesn't get that luxury. Six things change at once:
- The user uploads at runtime, not at commit-time. There's no
assets/folder to drop into — the folder lives in a virtual file system that the browser preview mounts on the fly. - The LLM decides where images go. It writes the
require()path itself, so the path we hand back must be one it can spell without hallucinating. - The preview iframe needs to see the asset instantly — no
npx expo start, no Metro warm-up. If uploading a logo takes 8 seconds to show up, the user thinks the product is broken. - Exports have to work in a different environment. A ZIP downloaded to a laptop must reference URLs that are still reachable outside of our infra.
- Assets have wildly different origins. Some come from user uploads. Some are generated by an image model. Some are pasted screenshots the vision model reads once and then discards. Some arrive inside a 20MB Lovable ZIP that needs unpacking.
- Some projects use a flat file tree; others use a monorepo layout. The same upload has to land in
assets/for one andmobile/assets/for the other.
Handling all six inside one coherent pipeline is the actual problem. Here's how it's built.
The pipeline at a glance
Every asset in a RapidNative project moves through five layers:
| Layer | What it does | Where it lives |
|---|---|---|
| Ingress | Validates and receives the upload | POST /api/upload, agent tool download_asset, Lovable signed-URL flow |
| Storage | Persists the raw bytes | Supabase Storage bucket projects, path {projectId}/fs/... |
| Catalog | Records what exists, where, and what kind | Postgres tables files, project_assets, external_project_uploads |
| Runtime | Makes assets visible to the generated app | Virtual file system + Metro-compatible flattened aliases |
| Egress | Emits assets on export or clone | /api/admin/export-project, project-clone routes |
Everything else — the icon generator, the vision pipeline, the Lovable importer — plugs into these five layers rather than owning its own private storage.
Layer 1: Ingress
There are three ways an asset gets into a project, and each has its own guardrails.
User uploads via POST /api/upload
The primary path. The endpoint requires an authenticated session (NextAuth), then does four things worth calling out:
- Workspace detection. Before choosing a destination path, the route checks the project's virtual file system for
mobile/package.jsonorexpo/package.json. If either exists, the project is a monorepo and uploads default tomobile/assets/instead of a repo-rootassets/. This detection is path-based, not a per-project flag — we deliberately avoided a config knob because it was one more thing to keep in sync. - Deduplication with nanoid. If a filename already exists, the route appends a short nanoid suffix. This keeps
logo.pngand a secondlogo.pngfrom clobbering each other without forcing the user to rename anything. - MIME and size validation. Only image MIME types are accepted through the agent tool path (
image/png,image/jpeg,image/gif,image/webp,image/svg+xml,image/x-icon), and there's a 5MB hard limit enforced by reading theContent-Lengthheader before streaming the body. - Path sanitization. Uploaded paths get restricted to alphanumeric +
._-and any..or.traversal attempts are rejected. This matters because the file path becomes therequire()string in generated code — an unchecked path is a code-injection surface.
The AI agent uploads via download_asset
The coding agent has its own tool that takes a URL and pulls the image into the project itself — the same route, wrapped as a callable tool. Living at src/lib/coding-agent/web-tools.ts, download_asset fetches the URL with a 30-second timeout, runs the same MIME + size checks, uploads via the shared storage service, and returns { ok, filePath, publicUrl, fileName, mimeType } back to the model. The filePath in that response is what the model then hard-codes into require('./assets/logo.png'). Because the tool response is the source of truth, the model doesn't need to guess the extension or the folder — it copies what we hand it.
The Lovable ZIP importer
This one is different because ZIPs blow past Vercel's 4.5MB serverless request body limit. So we use a two-step signed-URL flow:
POST /api/lovable-uploads/sign— server validates the requester and returns a signed upload URL for the privateexternal-project-zipsbucket. The bucket enforces a 25MB cap and restricts MIME to zip variants.- Browser uploads the ZIP directly to Supabase Storage using the signed URL — no serverless hop.
POST /api/lovable-uploads/complete— server confirms the upload, inserts a row intoexternal_project_uploads, and hands off to the conversion worker.
The external_project_uploads table tracks status (pending, processing, completed, failed), credit charges, and provenance (currently source = 'lovable', but the schema is generic — v0 and Bolt are on the roadmap). See the Lovable-to-RapidNative walkthrough for the full conversion story.
Layer 2: Storage
All bytes live in a single Supabase Storage bucket called projects. We renamed it from project-assets in a March 2026 migration because the bucket had grown to hold generated code files, migrations, and misc project artifacts — not just assets. The naming was misleading.
Inside the bucket, there are two path prefixes per project:
{projectId}/fs/...— anything the running app should see. Assets, source files, JSON configs.{projectId}/misc/...— supplementary files that shouldn't be part of the bundler's module graph.
The bucket has a public-read RLS policy on storage.objects, so any fs/ file is directly loadable from the preview iframe with no signed URL. Writes are service-role only; clients always go through our API routes.
The dual-path quirk
This one bit us for weeks. The Metro bundler running inside our browser preview flattens workspace paths differently than a real Metro instance on disk. In a monorepo, if the generated code says require('./assets/logo.png') from inside a screen at mobile/app/(app)/index.tsx, the browser bundler will look for assets/logo.png at the project root — not at mobile/assets/logo.png.
Fixing the bundler was expensive. Working around it was cheap: on upload, we write the same asset bytes to two storage paths — the real path ({projectId}/fs/mobile/assets/logo.png) and a flattened alias ({projectId}/fs/assets/logo.png). The DB row in files points to the real one; the flattened path is a bundler-only artifact. On export, we only emit the real path, so the ZIP is clean.
This is the kind of decision you make once, log carefully, and move on from. It's ugly, and it's the right tradeoff — a proper fix to the bundler would take weeks, and the dual-write costs a few kilobytes and one extra storage put per upload.
Layer 3: Catalog
Three Postgres tables carry the asset metadata:
files — the virtual file system for a project. Every source file, config, and asset has a row. Assets set is_external = true (added in migration 20260316100959_add_is_external_to_files.sql), which tells the bundler to fetch bytes from storage rather than reading inline content. Columns: project_id, kv_project_id, file_path, mime_type, file_type, content, file_size, is_external, timestamps.
project_assets — an inventory table specifically for uploaded/generated media, distinct from source files. Added in 20250903093026_create_project_assets_table.sql. Columns: id, kv_project_id, project_id, url, uploaded_at, and asset_type (added later, defaults to 'image', but also carries 'app_icon'). There's an index on (project_id, asset_type) so the app-icon picker query stays cheap.
external_project_uploads — the Lovable-import journal. Tracks the ZIP, who uploaded it, what got charged, and what status the conversion reached.
A pattern we chose deliberately: RLS enabled, no policies
project_assets and external_project_uploads have Row-Level Security enabled but no policies defined. That's not an oversight — it's a decision.
With no policies, no client with an anon or authenticated JWT can read or write the table directly. Every access has to go through service-role, which means every access has to go through one of our API routes. That's exactly what we want: authorization for asset access is contextual (does the caller own the project? are they a team member? is this a public share?), and expressing that in SQL alone gets hairy fast. Concentrating the auth logic in TypeScript route handlers keeps the rules readable and the audit surface small.
The files table is different — it has a public SELECT policy on storage.objects because the preview iframe needs public read for fs/* bytes. That's a bounded, well-understood exception, not a general pattern.
Layer 4: Runtime — how generated code sees assets
The generated Expo app running in the preview iframe doesn't know about Supabase Storage. It only knows about require() and the virtual file system.
When the model writes <Image source={require('./assets/logo.png')} />, the bundler resolves the path against the VFS. If the resolved row has is_external = true, the bundler swaps the require call for a runtime URL fetch to the Supabase public URL. The rendered <Image> shows the asset within one paint cycle of the upload finishing — no rebuild, no HMR-restart chain.
For a deeper look at the browser bundler itself, see how instant React Native preview works without a build step.
The Expo runtime handles @2x and @3x density variants natively, so nothing custom is needed there — Metro's own bundler behavior applies. What we care about is making sure the require() path we hand the model matches the file's real path in the VFS, exactly. Every mismatch is a "why is this broken?" support ticket.
Layer 5: Egress — export and clone
Exports go through /api/admin/export-project. Two things about it are worth noting:
- Metadata-only export. The route emits rows from the
filestable, but foris_external = truerows it emits an emptycontentand preserves the storage URL. The ZIP does not embed asset bytes; the exported code references the original Supabase public URL. - URLs remain live. Because the bucket has public read, an exported app running on a user's laptop keeps loading the same asset URLs. It works out of the box.
The obvious downside: if we ever rotate the bucket, all exported apps break. That's a known limitation, and the mitigation is that anyone actually shipping to production goes through expo export locally, which fetches assets over the network into the final bundle. We prefer this tradeoff over trying to re-host every asset on every export, which would double storage costs and slow down every download.
For cloning, we take the opposite approach: the clone worker re-uploads every asset into the new project's {newProjectId}/fs/... prefix. The bytes are copied server-to-server through the Supabase Storage copy() API (no round trip to our workers), and new project_assets rows are inserted pointing at the new URLs. This keeps clones independent — deleting the source project doesn't blow away the clone's images.
The special case: app icon generation
App icons don't come from users. They come from a diffusion model. The IconGeneratorService deserves its own paragraph because it exercises every layer of the pipeline.
When a user first sends a prompt on a new project, the service kicks off three parallel generations at FAL.ai (flux/schnell) — Minimal, Glyph, and Classic — each with a parametric prompt template. The template needs colors, which come from a four-step lookup:
- Read
mobile/theme.tsfor primary/background CSS variables. If present, parse them. - Convert
rgb(a b c)triples to hex. - Map hex to a named color from a 39-entry palette (diffusion models understand "sky blue" much better than
#38BDF8). - If theme.ts hasn't been written yet, hash the project description with a deterministic function and pick a palette from six defaults.
All three generated icons land at {projectId}/fs/assets/app-icon-{style}-{nanoid}.png, get catalogued in project_assets with asset_type = 'app_icon', and appear in the icon picker UI. When the user picks one, PUT /api/user/projects/[projectId]/app-icon overwrites {projectId}/fs/assets/images/icon.png with the chosen bytes and (unless keepAssets: true) sweeps up the other candidates from storage and DB.
The interesting bit is that DB insert failures for the generated icons are logged but non-blocking. If the row insert fails, the storage URL still works, the user still sees the icon, and the picker still functions from the cached URLs on the client. We chose availability over consistency here because a failed icon insert should not stop a project from generating.
What we'd change
Two things we'd revisit if we started over:
- The dual-path storage strategy would go away if we fixed the browser bundler's monorepo resolution. It's ~120 lines of workaround, one extra PUT per upload, and a source of subtle bugs when the two paths drift.
- The metadata-only export is a valid tradeoff for previews, but shipped apps should get a "bundle assets into ZIP" toggle. It's on the list.
The rest of the design has held up. RLS-enabled-without-policies is a pattern we've reached for again in other tables. The two-step signed-URL flow scales to files far larger than 25MB with just a config bump. The project_assets inventory table has become the anchor point for every asset-related feature we've added since — the app icon picker, the recent-uploads sidebar, the asset cleanup cron.
FAQ
How does RapidNative store images uploaded during app generation?
Uploaded images go to Supabase Storage under projects/{projectId}/fs/... with public read access. Metadata is inserted into files (with is_external = true) and inventoried in project_assets. The generated app references assets via require(), and the preview iframe resolves those to the public storage URL at runtime — no rebuild required.
Does RapidNative support monorepo asset paths for Expo projects?
Yes. The upload API detects mobile/package.json or expo/package.json in the project's virtual file system and routes uploads to mobile/assets/ automatically. To keep the browser bundler happy in preview, the same bytes are also written to a flattened alias path — the export ZIP contains only the real monorepo path.
What happens to images when I export a RapidNative project?
The ZIP includes source files but references assets by their live Supabase Storage URL rather than embedding the bytes. This keeps exports small and lets exported apps load images out of the box. For a production ship, running expo export locally will fetch and bundle the assets into the final IPA/APK.
What file size and format limits apply to uploads?
Image uploads are capped at 5MB and restricted to PNG, JPEG, GIF, WebP, SVG, and ICO. Lovable ZIP imports are capped at 25MB via a separate private bucket with a signed-URL upload flow.
Where this fits in the bigger picture
The asset pipeline is one piece of a broader architecture — the virtual file system, the browser-based preview bundler, the multi-LLM router, and the export pipeline that produces the final ZIP. Each layer has its own version of the same question: how do you make the AI's output feel like a real Expo project without the user having to know they're inside a hosted sandbox?
Assets are the layer where that illusion is easiest to break. A missing icon, a broken image, a slow-loading logo — any of it makes the whole thing feel like a demo. Getting the pipeline right isn't glamorous, but it's the difference between a preview that works and a preview that has to be explained.
If you want to see it in action, start a project on RapidNative, paste in a logo, ask for an app icon, and export the ZIP. The whole react native asset management pipeline described above runs in the background — no config, no assets/index.js, no manual density variants. It's the boring machinery that makes the interesting part feel like magic.
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.