Using TurboModule Substitution to Build Safer React Native Plugin Systems

Authors
Aliaksandr Babrykovich
Senior C++ Engineer
@
Callstack

In our previous article, we introduced direct communication between sandbox instances in react-native-sandbox. This time we're tackling a deeper problem: what happens when sandboxed code needs access to shared resources? How do you allow it in a safe way without compromising safety and stability?

The problem: Shared resources in TurboModules

By shared resources we mean anything that lives outside the JavaScript runtime and is accessed through native modules: the file system, persistent storage, hardware like the camera and microphone, location services, and so on.

When you run multiple React Native instances inside one app, each instance gets its own set of native module instances. However, those modules still may access the same underlying shared resources with no awareness of which sandbox is calling them.

This means a sandbox can:

  • Read or overwrite the host's data: whether it's files on disk or key-value storage, every instance operates on the same paths and databases by default.
  • Interact with hardware: if permission is granted to the app, camera, microphone, and other device APIs are accessible to any instance. Proper hardware access will likely require a separate permission management layer in the future.

Before, we had only one tool for this: the allowedTurboModules property: a whitelist that controls which additional native modules a sandbox can load. Anything not on the list is blocked entirely. And blocking isn't always the answer. A sandboxed micro-app might legitimately need access to the file system or the microphone, just not all the time, and not with the same scope as the host.

What we needed was a way to transparently swap a native module for a sandbox-safe alternative that scopes its behavior to the sandbox that's using it.

The solution: turboModuleSubstitutions

The new turboModuleSubstitutions prop lets the host declare module replacements per sandbox. When sandbox JS code requests a specific native module to load, the sandbox runtime can resolve a different native module instead.

<SandboxReactNativeView
  origin="plugin-a"
  componentName="PluginApp"
  jsBundleSource="plugin"
  allowedTurboModules={['RNFSManager', 'FileAccess', 'RNCAsyncStorage']}
  turboModuleSubstitutions={{
    RNFSManager: 'SandboxedRNFSManager',
    FileAccess: 'SandboxedFileAccess',
  }}
/>

When PluginApp calls FileSystem.writeFile(path, content), the request for FileAccess is intercepted and resolved to SandboxedFileAccess instead. The sandbox code doesn't know the difference. It uses the standard react-native-file-access API as usual.

Module Resolution Flow

A few key design decisions:

  • Substituted modules are implicitly allowlisted. If you declare a substitution, you don't also need to add the module to allowedTurboModules.
  • Runtime reconfiguration. Changing turboModuleSubstitutions at runtime triggers a full sandbox re-instantiation, ensuring the new module map takes effect cleanly.
  • No JS-side changes. The sandbox code imports and uses the original library. The swap happens entirely at the native module resolution layer.

Making modules sandbox-aware

Of course, we still need to write the sandboxed implementation ourselves. Depending on the module, this can be a trivial wrapper or a more involved task. Either way, the replacement module needs to know which sandbox it's serving. That's where the SandboxAwareModule protocol comes in.

When a substituted module is instantiated, the sandbox delegate checks whether it implements the sandbox-aware interface. If it does, the delegate calls configureSandbox with context about the sandbox instance: its origin, the module name that was requested, and the module name that was resolved.

In C++:

struct SandboxContext {
  std::string origin;            // e.g. "plugin-a"
  std::string requestedModuleName; // e.g. "RNCAsyncStorage"
  std::string resolvedModuleName;  // e.g. "SandboxedAsyncStorage"
};

class ISandboxAwareModule {
public:
  virtual void configureSandbox(const SandboxContext& context) = 0;
};

And the equivalent ObjC protocol:

@protocol RCTSandboxAwareModule <NSObject>

- (void)configureSandboxWithOrigin:(NSString *)origin
                     requestedName:(NSString *)requestedName
                      resolvedName:(NSString *)resolvedName;
@end

This is the hook that enables per-origin scoping. A sandboxed react-native-file-access implementation, for example, uses the origin to create an isolated directory structure and jail all file paths:

@interface SandboxedFileAccess : RCTEventEmitter <NativeFileAccessSpec, RCTSandboxAwareModule>
@property (nonatomic, copy) NSString *sandboxRoot;
@end

@implementation SandboxedFileAccess

- (void)configureSandboxWithOrigin:(NSString *)origin
                     requestedName:(NSString *)requestedName
                      resolvedName:(NSString *)resolvedName
{
    NSString *appSupport = NSSearchPathForDirectoriesInDomains(
        NSApplicationSupportDirectory, NSUserDomainMask, YES).firstObject;
    NSString *bundleId = [[NSBundle mainBundle] bundleIdentifier];
    _sandboxRoot = [[[appSupport stringByAppendingPathComponent:bundleId]
        stringByAppendingPathComponent:@"Sandboxes"]
        stringByAppendingPathComponent:origin];

    // Override all directory constants to sandbox-scoped paths
    _documentsDir = [_sandboxRoot stringByAppendingPathComponent:@"Documents"];
    _cachesDir    = [_sandboxRoot stringByAppendingPathComponent:@"Caches"];
    _libraryDir   = [_sandboxRoot stringByAppendingPathComponent:@"Library"];
}

After this call, every readFile, writeFile, and ls operation is confined to the sandbox's own directory tree. Path arguments that resolve outside sandboxRoot are rejected with EPERM. The same pattern applies to other modules like AsyncStorage. Storage is scoped to a per-origin directory so sandboxes can't see each other's data.

Real-world example: file system and storage isolation

The fs-experiment example app demonstrates substitution in practice. It shows a split-screen layout where the host and a sandbox run the same UI with a simple file operations interface that can write and read from react-native-fs, react-native-file-access, and AsyncStorage.

A toggle switches module substitution on and off. When substitution is off, the sandbox has direct access to the same storage and file paths as the host: you can write a value in the host and read it from the sandbox. When substitution is on, each side operates in its own isolated storage directory. The sandbox can no longer see the host's data.

const SANDBOXED_SUBSTITUTIONS = {
  RNFSManager: 'SandboxedRNFSManager',
  FileAccess: 'SandboxedFileAccess',
  RNCAsyncStorage: 'SandboxedAsyncStorage',
};

<SandboxReactNativeView
  origin="sandbox.fs-experiment.demo"
  componentName="SandboxApp"
  jsBundleSource="sandbox"
  allowedTurboModules={['RNFSManager', 'FileAccess', 'RNCAsyncStorage']}
  turboModuleSubstitutions={
    useSubstitution ? SANDBOXED_SUBSTITUTIONS : undefined
  }
/>

The sandbox JS code is identical in both cases. It uses the standard AsyncStorage and FileSystem APIs. The isolation is entirely transparent.

Beyond file system: other scenarios

File system and storage isolation is the most obvious use case, but the substitution mechanism is generic: it works with any TurboModule. Here are a few other scenarios where it could be useful:

  • Analytics scoping. Substitute the analytics module so each sandbox reports events tagged with its own origin. A third-party plugin can't pollute the host's analytics stream or see other plugins' event data.
  • Networking restrictions. Replace the networking module to restrict allowed domains, inject sandbox-specific auth headers, or enforce rate limits per sandbox.
  • Keychain & credentials. Scope secrets per sandbox so a plugin can store and retrieve its own tokens without access to the host's credentials.
  • Logging. Route sandbox logs to a separate destination or automatically prefix them with the sandbox origin for easier debugging in multi-instance setups.

Conclusion

TurboModule substitution, available in version 0.6.0, gives sandbox hosts granular control over what native capabilities sandboxed code actually gets. Instead of choosing between "full access" and "no access", you can provide scoped, safe alternatives that let sandboxes use real native functionality without compromising isolation.

Whether you're building a plugin system, a micro-frontend architecture, or a multi-tenant application, this pattern enables real native capability inside sandboxed boundaries.

To get started, check out the fs-experiment example and the complete API documentation.

Insights

Learn more about Super Apps

cover

Aug 31Article

Working With the React Native Super App Showcase Repository

Explore the React Native Super App Showcase architecture: Re.Pack, Module Federation, runtime bundles, shared dependencies, and production releases.

cover

Jul 21Article

A Step-By-Step Guide to Super App Development With Re.Pack 5

Learn how to connect a React Native host and mini app with Re.Pack 5 and Module Federation, then load the remote module at runtime.

cover

Jul 2Article

Mobile Module Federation with Re.Pack: When Runtime Delivery Is Worth the Complexity

Learn how Module Federation works in React Native with Re.Pack, when it makes sense, when simpler options are better, and what teams need to validate before adopting runtime delivery.

cover

Aug 27Article

Re.Pack 5.2: Faster Babel Transforms, RN 0.80 & 0.81, Rozenite

This release focuses on parallel, smarter transforms with a new Babel & SWC loader, official support for React Native 0.80 and 0.81, fully typed configs, support for Rozenite and more

cover

Jul 31Article

Running Multiple Instances of React Native in Sandbox

react-native-sandbox lets you run multiple React Native instances in one app. Build secure super apps and plugins with true runtime isolation, crash containment, and configurable permissions for native modules, perfect for integrating third-party code safely.

Event Cover

May 21Talk

Building Universal Super Apps With React & React Native

Discover how to build universal Super Apps using React and React Native. Join Mike Grabowski, CTO at Callstack, as he explores dynamic code delivery, native UI rendering, and how RePack bridges the gap with Module Federation.

Event Cover

May 20Live Stream

What’s New in Re.Pack 5.1: Live Overview

Explore runtime hooks, prefetching MF remotes, and more in Re.Pack 5.1. Join us live or watch the replay.

cover

May 14Article

New in Re.Pack 5.1: Preloading Remotes, Runtime Hooks, and Faster Startups

Re.Pack 5.1 introduces runtime hooks, preloading of remote modules with Module Federation, and experimental persistent cache for faster startups, plus full React Native 0.79 support.

cover

Jan 29Podcast

Mobile Microfrontends With Zephyr Cloud and Re.Pack

Get ready for faster development cycles and reduced risks when rolling out updates for mobile apps with microfrontends enabled by Zephyr Cloud and Re.Pack.

Event Cover

Jul 8Webinar

How to Scale Teams, Product Development, and Release Process With Re.Pack

Join us to learn how Re.Pack helps manage complexity in React Native apps, enables scalable teams, and boosts release processes.

cover

Nov 7Podcast

Module Federation

In the 18th episode of the React Native Show Podcast, Łukasz Chludziński, Paweł Trysła, and Zack Jackson talk about Module Federation in Webpack and Re.Pack. They also discuss some of the buzzwords related to this type of architecture, for example, super apps and micro frontends.

cover

Jul 6Podcast

Re.Pack - Bringing Webpack to React Native

This episode explores Re.Pack, an Open Source, Webpack-based toolkit to build React Native apps with the full support of the Webpack ecosystem.

cover

Mar 19Tutorial

The Hard Way vs. The React Native AI SDK Way: Stop Writing Custom Modules for Every Model

Learn how React Native AI uses a unified provider model to switch between cloud and on-device LLMs without rewriting application logic, enabling reliable AI experiences online and offline.

cover

Mar 12Tutorial

Build Smarter Apps: Tool Calling & AI Orchestration Explained

Learn how on-device models in React Native can call tools, interact with external APIs, and return structured outputs you can use directly in application logic.

cover

Mar 5Tutorial

MLC LLM + React Native: On-Device AI Without the Pain

Learn how to run third-party on-device LLMs in React Native using MLC LLM. Choose your models, run them efficiently across platforms, and keep a unified JavaScript API with React Native AI.

cover

Feb 24Tutorial

Intelligent Fallbacks with Apple Intelligence in React Native

Learn how to use Apple’s built-in Foundation Models in React Native apps, reduce memory usage, and enable fast, fully on-device AI with React Native AI Apple.

cover

Feb 10Tutorial

What Is the React Native AI SDK? A Complete Intro & Quickstart

Learn why on-device AI matters for React Native apps, how local LLMs behave offline, and what problems React Native AI is designed to solve from day one.

cover

Jan 29Tutorial

Implementing an Android TurboModule with Kotlin

Learn how to implement a React Native TurboModule on Android, from the TypeScript spec and Codegen to the Kotlin implementation and package registration.

cover

Jan 19Tutorial

Working with Different Threads in Swift TurboModules

Learn how JavaScript, native module, background, and main threads interact in React Native TurboModules, and how to use GCD queues to run async work safely in Swift.

cover

Jan 9Tutorial

How to Add Type-Safe Constants to Swift TurboModules

Learn how to expose typed, immutable constants from a Swift TurboModule using Codegen, and get full end-to-end type safety between native and JavaScript.

cover

Dec 30Tutorial

Adding Event Emitters to Your TurboModule in Swift

Learn how to emit native events from a Swift TurboModule and subscribe to them in JavaScript, including proper memory management and Codegen integration.

cover

Dec 22Tutorial

Writing Your First TurboModule in Swift

Learn how to build a fully working TurboModule in Swift, integrate it with Codegen, and bridge it through Objective‑C inside an Expo app.