React Native SDK
react-native-appspacer — A production-grade OTA update SDK for React Native. Push JavaScript bundle updates directly to your users’ devices with enterprise-level reliability and safety.
Overview
The AppSpacer SDK integrates into your React Native app to enable Over-The-Air updates. Once integrated, your app can:
- Check the AppSpacer server for new releases
- Download update packages with integrity verification
- Install bundles atomically — no partial updates, ever
- Roll back automatically if an update crashes on boot
- Display beautiful, themed update prompts with zero UI code
Requirements
| Dependency | Minimum Version |
|---|---|
| React Native | >= 0.72 |
| React | >= 18.0 |
| iOS deployment target | 13.0 |
| Android minSdk | 23 |
| AppSpacer backend | Running (self-hosted or cloud) |
Installation
npm install react-native-appspaceriOS
cd ios && pod install && cd ..Android
No additional steps — the package is auto-linked via React Native CLI.
Native Setup
Automatic (Recommended)
The fastest way to configure native files is one command:
npx appspacer setupThis auto-detects your project structure, identifies your React Native architecture, and injects the correct bundle resolver. See the CLI docs for all options.
After running setup, skip to SDK Initialization below.
Manual — Android
New Architecture (React Native 0.73+)
Uses ReactHostDelegate / ReactHostImpl to resolve OTA bundles dynamically.
// MainApplication.kt
import com.facebook.react.ReactHostDelegate
import com.facebook.react.ReactHostImpl
override val reactHost: ReactHost by lazy {
val delegate = object : ReactHostDelegate {
override fun getJsBundleFilePath(): String? =
com.appspacer.AppSpacerModule.getCustomBundlePath(applicationContext)
override fun getJsMainModulePath(): String = "index"
override fun getReactPackages(): List<ReactPackage> =
PackageList(this@MainApplication).packages
}
ReactHostImpl(applicationContext, delegate, SurfaceDelegateFactory { null }, true, true)
}Traditional Architecture (React Native < 0.73)
Uses ReactNativeHost to override the JS bundle file path.
// MainApplication.kt
override val reactNativeHost: ReactNativeHost =
object : DefaultReactNativeHost(this) {
override fun getJSBundleFile(): String? =
com.appspacer.AppSpacerModule.getCustomBundlePath(applicationContext)
override fun getJSMainModuleName(): String = "index"
override fun getPackages(): List<ReactPackage> =
PackageList(this@MainApplication).packages
override fun getUseDeveloperSupport(): Boolean = BuildConfig.DEBUG
}How it works:
getCustomBundlePathchecks for a downloaded OTA bundle at<internal-storage>/AppSpacerOTA/current_bundle/index.android.bundle. If it doesn’t exist, the default binary bundle is used.
Manual — iOS
Objective-C (AppDelegate.mm)
#import <AppSpacerModule.h>
- (NSURL *)bundleURL {
return [AppSpacerModule bundleURL];
}Swift (AppDelegate.swift)
import AppSpacerModule
override func bundleURL() -> URL? {
return AppSpacerModule.bundleURL()
}How it works: Returns the downloaded OTA bundle path if one exists on disk. Otherwise, falls back to the binary-embedded
main.jsbundle.
SDK Initialization
Call AppSpacer.init() once at app startup, before any update checks:
import { AppSpacer } from 'react-native-appspacer';
AppSpacer.init({
apiUrl: 'https://your-api.example.com/api',
deploymentKey: 'sk_YOUR_DEPLOYMENT_KEY',
appVersion: '1.0.0',
});Configuration Options
| Parameter | Type | Required | Default | Description |
|---|---|---|---|---|
apiUrl | string | ❌ | https://api.appspacer.com/api | Your AppSpacer backend URL |
deploymentKey | string | ✅ | — | Deployment key from the dashboard (e.g. sk_xxxx) |
appVersion | string | ✅ | — | Current native app version — must match target_version of your releases |
checkInterval | number | ❌ | 0 | Auto-check interval in seconds. 0 = manual only |
Auto-check every 5 minutes:
AppSpacer.init({
apiUrl: 'https://your-api.example.com/api',
deploymentKey: 'sk_YOUR_DEPLOYMENT_KEY',
appVersion: '1.0.0',
checkInterval: 300,
});Usage Patterns
Pattern 1: Zero-Config HOC (Easiest)
Wrap your root component with withAppSpacer for a fully managed experience. This handles initialization, update checking, beautiful update dialogs, and installation — all automatically.
import { withAppSpacer, InstallMode, UpdateUIType } from 'react-native-appspacer';
const App = () => {
return <MainNavigator />;
};
export default withAppSpacer({
deploymentKey: 'sk_YOUR_DEPLOYMENT_KEY',
appVersion: '1.0.0',
updateDialog: true, // Show premium themed update prompt
updateUI: UpdateUIType.GLASS, // Choose from 5 visual themes
installMode: InstallMode.IMMEDIATE, // Restart immediately after install
})(App);Built-in UI Themes
AppSpacer ships with 5 hand-crafted UI themes for the update dialog — no UI code required:
| Theme | Enum Value | Description |
|---|---|---|
| Premium Dark | UpdateUIType.PREMIUM_DARK | Sleek dark mode with vibrant glow effects (default) |
| Glass | UpdateUIType.GLASS | Modern glassmorphism with frosted transparency |
| Bottom Sheet | UpdateUIType.BOTTOM_SHEET | iOS-inspired slide-up sheet from the bottom |
| Minimal | UpdateUIType.MINIMAL | Clean, utility-focused light theme |
| Full Screen | UpdateUIType.FULL_SCREEN | Immersive, bold experience for major updates |
withAppSpacer Options
| Option | Type | Default | Description |
|---|---|---|---|
deploymentKey | string | Required | Your SDK deployment key |
appVersion | string | Required | Native app version (e.g. 1.2.0) |
updateDialog | boolean | false | Whether to show the premium popup |
updateUI | UpdateUIType | PREMIUM_DARK | The visual theme to use |
installMode | InstallMode | ON_NEXT_RESTART | When to apply the update |
apiUrl | string | https://api.appspacer.com/api | Your backend URL |
Pattern 2: Silent Auto-Update
Check, download, and install silently with immediate restart:
import { useEffect } from 'react';
import { AppSpacer, InstallMode } from 'react-native-appspacer';
function App() {
useEffect(() => {
AppSpacer.sync({ installMode: InstallMode.IMMEDIATE });
}, []);
return <MainApp />;
}Pattern 3: Install on Next Restart
Download silently in the background, apply on next cold start:
import { useEffect } from 'react';
import { AppSpacer, InstallMode } from 'react-native-appspacer';
function App() {
useEffect(() => {
AppSpacer.sync({ installMode: InstallMode.ON_NEXT_RESTART });
}, []);
return <MainApp />;
}Pattern 4: Install on App Resume
Download immediately, apply when the user returns to the app from the background:
import { useEffect } from 'react';
import { AppSpacer, InstallMode } from 'react-native-appspacer';
function App() {
useEffect(() => {
AppSpacer.sync({ installMode: InstallMode.ON_NEXT_RESUME });
}, []);
return <MainApp />;
}Pattern 5: Full UI Control
Build your own update experience using the useAppSpacerUpdate React hook:
import { useEffect } from 'react';
import { View, Text, Button, ActivityIndicator } from 'react-native';
import { useAppSpacerUpdate, UpdateStatus } from 'react-native-appspacer';
function App() {
const { status, update, error, checkForUpdate, downloadUpdate, restartApp } =
useAppSpacerUpdate();
useEffect(() => {
checkForUpdate();
}, []);
if (status === UpdateStatus.CHECKING) {
return (
<View style={{ flex: 1, justifyContent: 'center', alignItems: 'center' }}>
<ActivityIndicator size="large" />
<Text>Checking for updates...</Text>
</View>
);
}
if (status === UpdateStatus.UPDATE_AVAILABLE && update) {
return (
<View style={{ flex: 1, justifyContent: 'center', alignItems: 'center' }}>
<Text style={{ fontSize: 18, fontWeight: 'bold' }}>Update Available</Text>
<Text>{update.description}</Text>
{update.packageSize && (
<Text style={{ color: '#666' }}>
Size: {(update.packageSize / 1024 / 1024).toFixed(1)} MB
</Text>
)}
<Button title="Download & Install" onPress={downloadUpdate} />
</View>
);
}
if (status === UpdateStatus.DOWNLOADING) {
return (
<View style={{ flex: 1, justifyContent: 'center', alignItems: 'center' }}>
<ActivityIndicator size="large" />
<Text>Downloading update...</Text>
</View>
);
}
if (status === UpdateStatus.INSTALLED) {
return (
<View style={{ flex: 1, justifyContent: 'center', alignItems: 'center' }}>
<Text style={{ fontSize: 18 }}>✅ Update Ready</Text>
<Button title="Restart to Apply" onPress={restartApp} />
</View>
);
}
if (status === UpdateStatus.ERROR) {
return (
<View style={{ flex: 1, justifyContent: 'center', alignItems: 'center' }}>
<Text style={{ color: 'red' }}>Update error: {error}</Text>
<Button title="Retry" onPress={checkForUpdate} />
</View>
);
}
return <MainApp />;
}Pattern 6: Mandatory Updates
When you push a mandatory release from the CLI (--mandatory), the SDK auto-detects it and uses IMMEDIATE install mode regardless of your default:
import { useEffect } from 'react';
import { AppSpacer } from 'react-native-appspacer';
function App() {
useEffect(() => {
// Mandatory updates → auto IMMEDIATE
// Optional updates → ON_NEXT_RESTART
AppSpacer.sync();
}, []);
return <MainApp />;
}React Hook API
useAppSpacerUpdate()
A React hook that provides reactive state and actions for managing updates.
import { useAppSpacerUpdate } from 'react-native-appspacer';
const {
status, // UpdateStatus — current state of the update process
update, // AppSpacerUpdate | null — info about available update
error, // string | null — error message when status is ERROR
checkForUpdate, // () => Promise<void> — check server for updates
downloadUpdate, // () => Promise<void> — download and verify pending update
restartApp, // () => void — reload app with new bundle
sync, // (options?) => Promise<void> — full lifecycle helper
} = useAppSpacerUpdate();Core API Reference
AppSpacer.init(config)
Initialize the SDK. Must be called once before any other method.
AppSpacer.init({
apiUrl: 'https://your-api.example.com/api',
deploymentKey: 'sk_YOUR_KEY',
appVersion: '1.0.0',
checkInterval: 0,
});AppSpacer.checkForUpdate()
Check the server for available updates. Returns an AppSpacerUpdate object.
const update = await AppSpacer.checkForUpdate();
if (update.updateAvailable) {
console.log('New version:', update.label);
console.log('Description:', update.description);
console.log('Mandatory:', update.mandatory);
console.log('Size:', update.packageSize, 'bytes');
}AppSpacer.downloadUpdate()
Download, verify (SHA-256), and install the pending update. Must call checkForUpdate() first.
await AppSpacer.downloadUpdate();
// Update is now on disk, verified, and ready — restart to applyAppSpacer.sync(options?)
High-level helper that combines check → download → install → restart into a single call.
// Auto-restart immediately
await AppSpacer.sync({ installMode: InstallMode.IMMEDIATE });
// Apply on next cold start
await AppSpacer.sync({ installMode: InstallMode.ON_NEXT_RESTART });
// Apply when app comes back from background
await AppSpacer.sync({ installMode: InstallMode.ON_NEXT_RESUME });
// Default: IMMEDIATE for mandatory, ON_NEXT_RESTART for optional
await AppSpacer.sync();SyncOptions
| Option | Type | Default | Description |
|---|---|---|---|
installMode | InstallMode | ON_NEXT_RESTART | When to apply the update |
mandatoryInstallMode | InstallMode | IMMEDIATE | Override for mandatory updates |
updateDialog | boolean | UpdateDialogOptions | false | Show a native prompt |
updateUI | UpdateUIType | PREMIUM_DARK | Visual theme for the dialog |
minimumBackgroundDuration | number | — | Min seconds in background before applying (ON_NEXT_RESUME) |
AppSpacer.restartApp()
Reload the JavaScript bundle immediately.
AppSpacer.restartApp();AppSpacer.notifyAppReady()
Mark the current update as successfully running. Called automatically by init(), but you can call it explicitly after your app finishes loading critical screens.
AppSpacer.notifyAppReady();Important: If this method is never called after an update, the SDK assumes the update crashed and will roll back on the next boot.
AppSpacer.getStatus()
Returns the current UpdateStatus synchronously.
const status = AppSpacer.getStatus(); // e.g. UpdateStatus.IDLEAppSpacer.onStatusChange(listener)
Subscribe to status changes. Returns an unsubscribe function.
const unsubscribe = AppSpacer.onStatusChange((status, detail) => {
console.log('Status changed:', status, detail);
});
// Later:
unsubscribe();AppSpacer.destroy()
Tear down auto-check timers and AppState listeners. Safe to call multiple times. Useful during development hot-reloads.
AppSpacer.destroy();Types & Enums
AppSpacerConfig
interface AppSpacerConfig {
apiUrl?: string; // Backend URL (default: https://api.appspacer.com/api)
deploymentKey: string; // Deployment key (e.g. "sk_xxxx")
appVersion: string; // Native app version (e.g. "1.0.0")
checkInterval?: number; // Auto-check interval in seconds (default: 0)
}AppSpacerUpdate
interface AppSpacerUpdate {
updateAvailable: boolean; // Whether an update is available
releaseId?: string; // Server-assigned release ID
packageUrl?: string; // Download URL for the zip
hash?: string; // SHA-256 hash for verification
mandatory?: boolean; // Whether this is a mandatory update
description?: string; // Release description
label?: string; // Release label (e.g. "v1", "v2")
packageSize?: number; // Package size in bytes
}UpdateStatus
enum UpdateStatus {
IDLE // No update activity
CHECKING // Checking the server
UPDATE_AVAILABLE // An update is available
DOWNLOADING // Downloading the bundle
INSTALLING // Verifying and installing
INSTALLED // Ready, awaiting restart
UP_TO_DATE // Already on latest version
ERROR // Something went wrong
}InstallMode
enum InstallMode {
IMMEDIATE // Restart immediately after install
ON_NEXT_RESTART // Apply on next cold start
ON_NEXT_RESUME // Apply when app returns from background
}UpdateUIType
enum UpdateUIType {
PREMIUM_DARK // Sleek dark mode with glow effects (default)
GLASS // Modern glassmorphism
MINIMAL // Clean minimalist light theme
BOTTOM_SHEET // iOS-inspired slide-up sheet
FULL_SCREEN // Immersive full-screen experience
}UpdateDialogOptions
interface UpdateDialogOptions {
title?: string; // Default: "Update Available"
message?: string; // Default: "An update is available..."
installButtonLabel?: string; // Default: "Install"
ignoreButtonLabel?: string; // Default: "Ignore"
mandatoryContinueButtonLabel?: string; // Default: "Continue"
mandatoryUpdateMessage?: string; // Default: "An update is available that must be installed."
appendReleaseDescription?: boolean; // Default: true
}Update Lifecycle
App Boots
↓
AppSpacer.init() ← calls notifyAppReady() automatically
↓
IDLE → CHECKING → (no update) → UP_TO_DATE
→ (update found) → UPDATE_AVAILABLE
↓
DOWNLOADING
↓
INSTALLING
↓
INSTALLED
↓
┌──────────┼──────────────┐
↓ ↓ ↓
IMMEDIATE ON_NEXT_RESTART ON_NEXT_RESUME
(restart (waits for (waits for app
now) cold start) to resume)
↓ ↓ ↓
App reloads with new bundle
↓
notifyAppReady() ← confirms update is stableIf any step fails, the status transitions to ERROR.
Safety & Rollback
Automatic Crash Rollback
The SDK protects your users from broken updates with a multi-layer safety system:
- When an update is installed, the SDK sets a
PENDINGflag - On next boot,
init()callsnotifyAppReady(), which flips the flag toSUCCESS - If the app crashes before
notifyAppReady()runs, the native module detects the stalePENDINGstate on next boot and:- Deletes the broken bundle
- Restores the previous stable bundle
- Falls back to the binary-embedded bundle if no previous version exists
SHA-256 Verification
Every downloaded package is verified against the server-provided SHA-256 hash before extraction. If the hash doesn’t match, the update is rejected and the download is discarded.
Atomic Install
The update process uses an atomic swap pattern to prevent partial installs:
current_bundle/→ renamed toprevious_bundle/- New update → extracted and renamed to
current_bundle/
This ensures the app always has a valid bundle to load.
Hermes Compatibility
The SDK is fully compatible with Hermes engine. It uses a hash-routing system where new updates are extracted to immutable update_[hash] directories. SharedPreferences updates a pointer — this completely bypasses Hermes MMAP file locks.
Troubleshooting
| Issue | Cause | Solution |
|---|---|---|
Native module not found | Native module not linked | Run pod install (iOS) and rebuild the app |
| Update not detected | Version mismatch | Ensure appVersion in init() matches the target_version of your release |
| Bundle not loading after restart | Native setup incomplete | Verify bundleURL (iOS) and getJSBundleFile (Android) are overridden — or re-run npx appspacer setup |
FORBIDDEN error on CLI | Auth issue | Verify you’re a member of the app’s organization |
| Network timeout | Server unreachable | Default timeout is 15s — check server health and connectivity |
| Update keeps rolling back | Crash before notifyAppReady | Ensure AppSpacer.init() runs early in app startup, before any crash-prone code |
| Stale bundle after update | Old cached bundle | Clear app data or reinstall to reset OTA state |
| Update doesn’t restart | Native setup missing | Run npx appspacer setup or manually update MainApplication.kt |
| Assets missing | Assets not included in bundle | Use --include-assets flag when pushing updates |
FAQ
Q: What happens if the user has no internet?
A: checkForUpdate() will throw an error, status becomes ERROR. The app continues running the current bundle normally.
Q: Can I update native code (Java / Kotlin / Obj-C / Swift)? A: No. OTA updates only apply to the JavaScript bundle and assets. Native code changes require a full app store release.
Q: How big can an update be? A: There’s no hard limit from the SDK, but keep bundles small (< 20 MB) for the best user experience. The SDK streams the download to disk.
Q: Is the update delta or full? A: Currently full-bundle only. Delta updates are on the roadmap.
Q: Can I roll back a bad update from the server?
A: Yes — use appspacer rollback -a <app> -d <deployment> -p <platform>. The SDK also auto-rolls back on crash.
Q: Does the SDK work with Hermes?
A: Yes. Bundle the Hermes bytecode (.hbc) as you normally would, zip it, and push via CLI.
Q: What about Expo? A: Not currently supported. The SDK requires native module linking which is not available in Expo Go. It may work with a custom dev client (bare workflow).
Q: Is appspacer setup safe to run multiple times?
A: Yes. It’s idempotent — it detects existing injections using // APPSPACER_START / // APPSPACER_END markers and replaces them cleanly.