How RapidNative Auto-Manages React Native Dependencies

(156 chars):** Inside RapidNative's dependency system: how an add-only package.json merge, pinned Expo SDK versions, and byte-preserving guards keep AI-generated apps stable.

RI

By Riya

27th Jul 2026

Last updated: 27th Jul 2026

How RapidNative Auto-Manages React Native Dependencies

The single worst class of bug in AI-generated mobile apps has nothing to do with your prompt. It happens when the model rewrites package.json, bumps expo from 54.0.13 to latest, and — at runtime, inside the preview iframe — throws:

TypeError: Cannot read properties of undefined (reading 'S')

That error is not a syntax mistake. It is React 18 internals colliding with React 19 internals because two versions of react ended up in the same bundle. And the model did not do anything obviously wrong: it just tried to add expo-camera to a project and helpfully "cleaned up" the surrounding dependency block while it was in there.

React Native dependency management is unforgiving. Expo publishes a bundledNativeModules.json mapping every native module to the exact version compatible with each SDK release. Break that mapping — even by a patch number, in the wrong place — and your app either won't build or will crash the first time a screen renders. For a human developer this is annoying. For an AI code generator producing thousands of edits per day across thousands of projects, it is fatal.

This post walks through how RapidNative handles it. We built a dependency system that treats the AI as untrusted input to the build graph, freezes what already works, and only allows additive changes to dependencies and devDependencies. Everything else about package.json — the Expo version, the React version, the React Native version, the Reanimated version — is preserved byte-for-byte on every generation.

Developer looking at code on a laptop screen Dependency drift in AI-generated code is silent — it doesn't fail linting, it fails at runtime. Photo by David Rangel on Unsplash

Why AI-Generated Apps Break at the Package Layer

To understand the guardrail, you need to understand the failure mode. A typical AI code generator, when asked to "add a camera screen," will do three things:

  1. Generate a new screens/CameraScreen.tsx file.
  2. Add import { CameraView } from 'expo-camera'; at the top.
  3. Rewrite the whole package.json file to include "expo-camera": "*" (or, worse, "expo-camera": "^17.0.10").

The third step is where everything falls apart. In a preview environment where the bundler resolves versions directly from package.json — the way our almostmetro bundler does — a version specifier of "*" resolves to @latest. expo-camera@latest was built against a newer Expo SDK than the project. Its createPermissionHook implementation calls into an expo module that expects a native module shape the older SDK doesn't provide. The screen mounts, the hook fires, and you get a stack trace you cannot debug from a prompt.

The same class of bug hits expo-location, expo-notifications, expo-image-picker, react-native-reanimated, and every other package that binds to native code through Expo modules. Two things make it especially insidious:

  • Type checks pass. The .d.ts files are usually forward-compatible; TypeScript is happy.
  • Static analysis passes. ESLint sees a valid import from a package that exists in package.json. Nothing looks wrong.
  • The error is version-boundary specific. It only fires when the transitive react-native or expo module resolved at runtime doesn't match what the surface package was compiled against.

For a hand-written project you'd catch it in code review or the first local build. For an AI-generated project that renders live in a browser preview two seconds after the model finishes streaming, you catch it when the user sees a broken screen and files a support ticket.

The RapidNative Base Template: Every Version Explicit, Nothing Loose

The first design decision was to eliminate ranges entirely from our starter templates. Look at the actual template every new RapidNative project ships with (src/modules/editor/utils/packageJsonTemplate.ts):

{
  "dependencies": {
    "expo": "54.0.13",
    "expo-router": "6.0.12",
    "expo-linear-gradient": "15.0.7",
    "expo-image": "3.0.9",
    "expo-haptics": "15.0.7",
    "react": "19.1.0",
    "react-dom": "19.1.0",
    "react-native": "0.81.4",
    "react-native-reanimated": "4.1.3",
    "react-native-gesture-handler": "2.28.0",
    "react-native-safe-area-context": "5.6.1",
    "react-native-screens": "4.16.0",
    "react-native-svg": "15.14.0",
    "react-native-web": "0.21.1",
    "nativewind": "4.2.1",
    "tailwindcss": "3.4.18",
    "lucide-react-native": "0.510.0"
  }
}

Every single version is an exact pin. No ^, no ~, no *, no latest. This is deliberate. Semver ranges in a preview bundler are non-deterministic — the resolved version depends on what the registry served when the bundle was built, which means the same project can produce different bundles on different days. For an AI product where reproducibility is the entire brand promise, that is unacceptable.

Pinning to exact versions costs us the "free upgrade to patch fixes" that ranges normally provide. That is a trade we happily make. When Expo 54 patches something we care about, we bump the template, regenerate the pinned set, and roll it forward. The alternative — every project drifting silently under our feet — was tried and abandoned.

Terminal window showing npm output Exact-pinned versions eliminate a whole class of "worked yesterday" bugs. Photo by Gabriel Heinzer on Unsplash

The Add-Only Guard: How We Constrain What the Model Can Do

Locking down the template solves one half of the problem. The other half is what happens on every subsequent generation, when the AI writes a new package.json that touches existing dependencies.

Our answer is a post-processor called packageJsonGuardProcessor, defined in src/shared/utils/postProcessor.ts. It runs after streaming completes and before the AI's file writes are committed to the project. It enforces one rule:

The only legal change to an existing package.json is a brand-new key appended to dependencies or devDependencies. Every other byte is preserved from the version that already existed.

That is a much stronger guarantee than "diff-based merge." Consider what happens when the AI generates a full-file rewrite that:

  • Uses the same "expo": "54.0.13" (allowed, unchanged)
  • Renames "expo-camera" from "17.0.10" to "17.0.11" (disallowed — version drift)
  • Removes "expo-haptics" (disallowed — deletion)
  • Adds "expo-camera": "*" for a new project without it (allowed, but pinned — see next section)
  • Reorders the keys alphabetically (disallowed — byte drift)

The guard rejects all of that except the addition. Here's the merge itself:

export function mergePackageJsonAddOnly(
  aiContent: string,
  existingContent: string | undefined
): { content: string; addedDependencies: string[]; changed: boolean } {
  if (!existingContent || !existingContent.trim()) {
    return { content: aiContent, addedDependencies: [], changed: false };
  }

  const existing = tryParseJsonLoose(existingContent);
  const incoming = tryParseJsonLoose(aiContent);

  const known = new Set<string>([
    ...Object.keys(asObject(existing.dependencies)),
    ...Object.keys(asObject(existing.devDependencies)),
  ]);

  const pick = (incomingMap: Record<string, string>) => {
    const out: Record<string, string> = {};
    for (const k of Object.keys(incomingMap)) {
      if (known.has(k)) continue;
      out[k] = incomingMap[k];
      known.add(k);
    }
    return out;
  };

  const newDeps = pick(asObject(incoming.dependencies));
  const newDevDeps = pick(asObject(incoming.devDependencies));

  let content = existingContent;
  if (Object.keys(newDeps).length) {
    content = appendToPackageJsonBlock(content, 'dependencies', newDeps).content;
  }
  if (Object.keys(newDevDeps).length) {
    content = appendToPackageJsonBlock(content, 'devDependencies', newDevDeps).content;
  }

  return { content, addedDependencies: [], changed: content !== existingContent };
}

Two properties worth pointing out. First, the merge starts from existingContent — not from the parsed JSON — which means any comments, formatting, or trailing commas the user added by hand are preserved. Second, the writes go through appendToPackageJsonBlock, which locates the "dependencies": { block by regex, walks the character stream to find the matching closing brace, and inserts new entries just before it — no JSON.stringify, no key reordering, no whitespace normalization. The rest of the file is untouched, byte-for-byte.

What "Byte-Preserving" Actually Means in Practice

Byte-preserving edits sound like a nice-to-have. In an AI codebase, they are load-bearing. Here is why: every time you re-serialize JSON, you lose information the original file carried. Property order changes. Whitespace normalizes. Comments (if you were using JSONC) vanish. And every one of those changes shows up as a diff in the project's version history, making it impossible to tell what the AI actually intended to change versus what serialization did as a side effect.

By threading edits through the raw string, we get diffs that mean something: if the file diff shows a new "expo-camera" line appended before the closing }, that is because the model wanted to add expo-camera. Full stop. No expo line moved, no version rewrote itself, no react-native-reanimated line silently disappeared and reappeared. The signal-to-noise ratio on our version history tab stays clean, and users can trust that "this generation added expo-camera" means only that.

The Pinned Version Registry: Blocking "*" at Ingest

There is one more subtle failure mode the add-only guard doesn't solve. When the model correctly follows its system prompt and adds a new dependency as "expo-camera": "*", the add-only guard happily appends it — and now "*" reaches the bundler, which fetches @latest, and we are back in crash territory.

The fix is a small hardcoded registry:

export const PINNED_DEPENDENCY_VERSIONS: Record<string, string> = {
  "expo-location": "~19.0.8",
  "expo-camera": "~17.0.10",
};

Every code path that touches package.json runs pinPackageVersions() at the end, which does a byte-preserving regex swap: for any package name in the registry, the version token gets replaced with the pinned value, everywhere it appears. This is a backstop, not the primary defense — the primary defense is the system prompt that tells the model to add new deps as "*" so the pinning logic can enforce a canonical version. But defense in depth matters: streaming passes, single-file rewrites, lenient-parse bails, and full-file pass-throughs on brand-new projects all route through pinPackageVersions() before commit.

The pinned set is small on purpose. We only add packages here when we have a concrete Expo-SDK-compatibility reason — usually because a newer version's TypeScript signature or native module shape doesn't work against the SDK the preview bundler ships. The docstring in the code makes this explicit: "Keep these aligned with the project's Expo SDK (expo/bundledNativeModules.json)."

When Expo publishes a new SDK, we run through the same review, bump the pinned versions, and ship a template update. The system does not chase moving targets automatically. Anyone who's worked with React Native for more than a year understands why.

Person working on mobile app design The right dependency version is a moving target. Freezing it is a feature, not a limitation. Photo by Nordwood Themes on Unsplash

The System Prompt: Telling the Model What Packages Exist

Post-processing guards are the last line of defense, not the first. Ideally the model never generates a broken import in the first place. We accomplish that with an explicit instruction in the system prompt used by our code generation pipeline (src/lib/coding-agent/system-prompt.ts):

IMPORTS — NO PHANTOM PACKAGES Never import modules, packages, or files that do not already exist in the project scaffold. The app uses Expo + React Native + NativeWind — only import from packages listed in package.json and files you have written yourself.

This rule is enforced at two additional layers. First, before generation runs, our resolveDirectLocalImports() step reads the actual project scaffold and passes the list of known packages as context. The model sees what is available before it decides what to reach for. Second, our post-processing pipeline includes an importFixerProcessor that scans generated files for imports that don't resolve — either to a known local file or to a known package — and either fixes them (for known aliasing patterns) or flags them for the model to regenerate.

The result is that "phantom package" imports — the class of hallucination where the model imports from @shadcn/react-native (which doesn't exist) or @heroicons/react-native (also doesn't exist) — happen an order of magnitude less often. When they do slip through, the guard catches them before they reach a user's preview.

How This Works With Different Project Types

Not every RapidNative project is a single-workspace Expo app. Our newer fullstack-v2 template ships as a monorepo with a mobile/ workspace (Expo), an api/ workspace (Express), and a web/ workspace (static). The rapidnative.json config at the root describes the structure:

{
  "type": "fullstack-v2",
  "paths": {
    "root": "mobile",
    "alias": { "@": "mobile" }
  },
  "workspaces": {
    "mobile": { "type": "expo", "root": "mobile" },
    "api":    { "type": "express", "root": "api", "entry": "index.js" },
    "web":    { "type": "static",  "root": "web" }
  }
}

In this layout, package.json lives at mobile/package.json — not at the project root. All the guards described above still apply; they just apply to the file path the workspace config points at. The bundler's isBundleablePath filter looks at rapidnative.json to figure out which files are part of the mobile bundle in the first place, so server-side dependencies in api/ don't accidentally get pulled into the mobile graph. This isolation is what makes it safe to describe full-stack generations in a single prompt: the mobile app and the API have separate dependency worlds and stay that way.

What the User Never Sees

The best measure of a dependency system is how invisible it is. A user prompting "add a barcode scanner screen using the camera" should not need to know that:

  • expo-camera was appended to dependencies at the end of the block, with no other changes to the file
  • The version was rewritten from the model's "*" output to "~17.0.10" before commit
  • The bundler resolved that specific patch against the Expo 54 SDK it ships with
  • A byte-for-byte diff of package.json shows one new line and nothing else
  • If the model had also tried to bump expo to 55.0.0 for some unrelated reason, that change would have been silently reverted

They see: the barcode scanner works when they scan the QR code with Expo Go, and again when they export the project as a standalone repo.

That invisibility is worth a lot. Anyone who has spent a Saturday debugging why a React Native project builds on their machine but not on a coworker's understands the value of a system that never lets version drift into the picture in the first place.

Frequently Asked Questions

How does RapidNative decide which dependency versions to use?

RapidNative ships with a base template where every dependency version is exact-pinned to a specific patch release compatible with a specific Expo SDK (currently SDK 54). New dependencies added by the AI are pinned to versions we've validated against that SDK. There are no semver ranges (^, ~, *) in generated projects — every version is deterministic.

What happens if the AI tries to upgrade my Expo version?

It can't. The packageJsonGuardProcessor runs an add-only merge: any change to an existing dependency version, including expo, react, or react-native, is silently reverted. Only brand-new keys added to dependencies or devDependencies are kept. This protects your project from unintended SDK upgrades even if the model produces a full-file rewrite.

How does this interact with a lockfile?

RapidNative's preview bundler resolves versions directly from package.json, so there is no lockfile involved during editing. When you export a project, you run npm install (or bun install) yourself in your local environment, which generates a lockfile from the exact versions in package.json. Because those versions are pinned, the lockfile you produce today will resolve the same tree six months from now.

Can I manually edit package.json to add a dependency?

Yes. The add-only guard runs on AI-generated writes, not on user edits. If you open package.json in the editor and add a new dependency, that goes straight through. The system assumes your explicit edits are intentional; the AI's edits are guarded because they are inferred from natural language and cannot be fully trusted.

Where This Fits in the Broader Architecture

Dependency management is one piece of a larger design goal: making AI-generated code that behaves like code a human wrote. That goal shows up in our TypeScript generation strategy, our error handling patterns, and our approach to reducing hallucinations by 80%. Each piece is small on its own. Together they form the difference between a demo that renders a "hello world" screen and a system that produces apps you can ship to the App Store.

If you want to see the guards in action, start a new project on RapidNative, generate a few screens that use different Expo modules, and watch the package.json on the pricing-eligible export. You'll see appended lines, exact-pinned versions, and no diff noise. That is the design working the way it was meant to.

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.