Guide
Getting started
nsh’s main use is hosting modules inside a native app (see Introduction). This guide follows that path: write a module, develop it with hot reload, show it in your iOS or Android app, and ship an update as a signed package. It assumes nsh is installed and on your PATH (Installation); in a checkout, use ./target/debug/nsh instead.
import { mount, signal, Text, View } from "nsh";
// The app `nsh init` creates, with a reset button and a short history.
function App() {
const count = signal(0);
const history = signal<number[]>([]);
const change = (delta: number) => {
const next = count() + delta;
count.set(next);
history.update((h) => [next, ...h].slice(0, 4));
};
return (
<View style={{ flex: 1, backgroundColor: "#f6f8fa", padding: 20, paddingTop: 24, alignItems: "center", justifyContent: "center", gap: 16 }}>
<Text style={{ fontSize: 15, color: "#6e7781" }}>You tapped</Text>
<Text style={{ fontSize: 56, fontWeight: "700", color: count() < 0 ? "#cf222e" : "#1f2328" }}>{count()}</Text>
<View style={{ flexDirection: "row", gap: 12 }}>
<View
onPress={() => change(-1)}
activeOpacity={0.6}
accessibilityRole="button"
style={{ paddingHorizontal: 20, paddingVertical: 12, borderRadius: 10, backgroundColor: "#ffffff", borderWidth: 1, borderColor: "#d0d7de" }}
>
<Text style={{ color: "#1f2328", fontSize: 17 }}>−1</Text>
</View>
<View
onPress={() => change(1)}
activeOpacity={0.6}
accessibilityRole="button"
style={{ paddingHorizontal: 20, paddingVertical: 12, borderRadius: 10, backgroundColor: "#0969da" }}
>
<Text style={{ color: "white", fontSize: 17 }}>Tap</Text>
</View>
</View>
<If when={history().length > 0}>
<Text style={{ fontSize: 13, color: "#6e7781" }}>Recent: {history().join(", ")}</Text>
</If>
<View onPress={() => (count.set(0), history.set([]))} hitSlop={8}>
<Text style={{ fontSize: 15, color: "#0969da" }}>Reset</Text>
</View>
</View>
);
}
mount(App);#Create a module
nsh init my-app
A module is what a host app loads: one or more .tsx files with an entry. nsh init creates the directory if it is missing and writes:
| File | What it is |
|---|---|
App.tsx | The entry module: renders the app with mount |
nsh.json | The app’s name, id, version and build number (Project structure) |
tsconfig.json | TypeScript settings that resolve nsh to the declarations in .nsh/types |
.gitignore | Ignores .nsh/ and out/ |
.nsh/types/ | Generated declarations of every nsh module, for your editor |
The display name defaults to the directory’s name; pass --name "My App" to choose another. The id becomes com.example.<name>: change it in nsh.json before you ship. Files that already exist are kept, so you can run nsh init in a directory that has some of them.
#The entry module
import { mount, signal, Text, View } from "nsh";
import { SafeAreaView } from "nsh/ui/mobile";
function App() {
const count = signal(0);
return (
<SafeAreaView style={{ flex: 1, alignItems: "center", justifyContent: "center", gap: 16 }}>
<Text style={{ fontSize: 28, fontWeight: "600" }}>{count()}</Text>
<View
onPress={() => count.update((c) => c + 1)}
activeOpacity={0.6}
accessibilityRole="button"
style={{ paddingHorizontal: 20, paddingVertical: 12, borderRadius: 10, backgroundColor: "#0969da" }}
>
<Text style={{ color: "white", fontSize: 17 }}>Tap</Text>
</View>
</SafeAreaView>
);
}
mount(App);
Line by line:
import { mount, signal, Text, View } from "nsh":nshis the core module: signals,mount, the other reactive primitives, and the host elementsViewandText. It is served by the native runtime, not bundled.import { SafeAreaView } from "nsh/ui/mobile": the mobile UI set.SafeAreaViewpads its content so it stays clear of the notch, the status bar and the home indicator.function App()is a component: a function that returns JSX. It runs once.signal(0)makes a value that changes over time.{count()}reads the signal inside JSX. The compiler sees a call and makes this text follow the signal: whencountchanges, the runtime updates this one text, nothing else.<View onPress>is a native view that reacts to presses;activeOpacityfades it while pressed, natively.View,Text,Image,Scroll,Inputand the other host elements are imported from nsh’s modules, like any component; onlyIfandEachneed no import.style={{ … }}takes an object of camelCase style properties, with numbers in points. Layout is flexbox, and children stack in a column by default (Layout).mount(App)renders the component into the app’s surface.
#Develop it
#Run it
nsh run starts a dev server, builds the app, launches a development host that loads it, and keeps rebuilding while you edit. Logs from the app (console.log, errors) appear in the terminal.
#Mac Catalyst (default)
nsh run my-app/App.tsx
The app opens in an iPhone-sized window (390×844 points) on your Mac. This is the fastest loop: no simulator to boot. It uses UIKit like iOS, so what you see is what an iPhone shows, apart from Mac-specific behavior such as scrolling with a mouse wheel.
#iOS simulator
nsh run my-app/App.tsx --target ios
nsh run my-app/App.tsx --target ios --device "iPhone 16 Pro"
nsh uses a booted simulator, or boots the newest iPhone if none is running, and installs the development host into it. --device picks a simulator by name or UDID.
#Android
nsh run my-app/App.tsx --target android
Start an emulator or connect a device first (adb devices lists them; nsh uses the first one). nsh installs a development host APK (rebuilt only when the runtime changes) and forwards the dev server’s port to the device with adb reverse, so it works over USB without a shared network.
#Edit with hot reload
Leave nsh run running and change the label in App.tsx:
<Text style={{ color: "white", fontSize: 17 }}>Add one</Text>
Save. The CLI rebuilds and sends a patch; the host runs the changed module again and renders again only the components whose code changed. Here that is App, which owns the count signal, so the count starts over. State survives when you edit a component that does not own it. Move the button into a component of its own:
function TapButton(props: { onPress: () => void }) {
return (
<View onPress={props.onPress} activeOpacity={0.6} style={{ padding: 12, borderRadius: 10, backgroundColor: "#0969da" }}>
<Text style={{ color: "white", fontSize: 17 }}>Tap</Text>
</View>
);
}
Use <TapButton onPress={() => count.update((c) => c + 1)} /> in App, tap a few times, then change TapButton’s color: the button updates and the count stays.
Hot replacement works per top-level component: a function with a capitalized name whose body contains JSX. A component renders again when its own code changes, or when the module’s code outside components changes (a constant it uses, a helper). If an edit cannot be applied in place (for example, it imports an nsh module the running app did not import yet, or changes nsh.json’s capabilities), the host reloads the whole app.
#Errors
A build error is printed in the terminal and shown on the device as a red overlay with the file, line and column; the app keeps running the last good build. The overlay disappears with the next successful build (tap it to dismiss it earlier). An exception thrown at run time is shown the same way, with its stack mapped back to your .tsx source.
#Host it in your app
Your app shows a module in a view of its own: NshAppView (a UIView) on iOS, NshView (an Android View) on Android. Place it like any other view, on any screen, as many times as you need. During development, point it at the dev server; in a release, load a built bundle or a downloaded package.
#iOS
Link NativeShellDev.xcframework in Debug and NativeShell.xcframework in Release (Embedding in iOS apps), then:
import NativeShell
let app = NshAppView()
app.onError = { message in print("nsh error: \(message)") }
view.addSubview(app) // and constrain it like any view
#if DEBUG
try app.connectDevServer(URL(string: "ws://127.0.0.1:8790/ws")!) // `nsh dev my-app` on your Mac
#else
try app.loadBundle(at: Bundle.main.url(forResource: "bundle", withExtension: "qbc", subdirectory: "nsh")!)
#endif
nsh dev my-app/App.tsx serves the module to the app and pushes every save, as nsh run does with its own host. For the release build, nsh build my-app/App.tsx --release --out MyApp/nsh writes the bundle into a folder you add to the app’s resources.
#Android
Add nsh-dev.aar for debug builds and nsh.aar for release builds (Embedding in Android apps), then:
val nsh = NshView(this)
nsh.setErrorListener { message -> Log.e("nsh", message) }
setContentView(nsh) // or add it to any layout
if (BuildConfig.DEBUG) {
nsh.connectDevServer("ws://127.0.0.1:8790/ws") // with `adb reverse tcp:8790 tcp:8790`
} else {
nsh.loadAssetBundle("nsh") // assets/nsh, from `nsh build my-app/App.tsx --release --out app/src/main/assets/nsh`
}
Before loading, the host decides what the module may do: grantCapabilities sets the network hosts and other capabilities it may reach, and limits cap its memory, call time and node count (Sandbox and capabilities). To give modules your own native views (a map, a chart, a design-system component), register them as native components.
#Ship updates as signed packages
A module the app downloads after release comes as a signed package. Create a root key once and keep it offline, certify a release key with it, and publish:
nsh keygen keys/root && nsh keygen keys/release
nsh certify --root keys/root.secret --key keys/release.pub --apps 'com.example.*' -o keys/release.cert
nsh publish my-app/App.tsx --key keys/release.secret --cert keys/release.cert --build 2
The app trusts the root’s public key and loads what it downloads; nsh verifies the signature, the certificate and the revocation list before running anything:
let support = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask)[0]
let trust = try NshTrust(stateDirectory: support.appendingPathComponent("nsh"))
try trust.addRoot(rootPublicKey) // the contents of keys/root.pub
let info = try await app.loadPackage(at: downloadedFile, trust: trust)
Android has the same flow with NshTrust and NshPackage. Hosting, delivery and revocation in detail: Signed packages.
#Or ship it as a standalone app
The same module can also be a whole app. nsh package builds an installable app around it from the prebuilt runtime, with no Xcode project or Gradle build of your own:
nsh package my-app --target ios-simulator --run
nsh package my-app --target android
This is a supported use, and the quickest way to put a module on a device, but nsh is designed around hosting modules in native apps. Options, signing and icons: Packaging.
#Try it in the terminal
nsh trace runs an app headless (no device) and prints the native view operations it produces and their timings, then taps the first pressable view twice:
nsh trace my-app/App.tsx
It is a quick way to check that an app starts without errors, and to see how much work an interaction causes (Testing and trace).
#Next steps
- Embedding in iOS apps and Embedding in Android apps: the full host APIs.
- Signed packages and Sandbox and capabilities: delivery and what modules may do.
- JSX and components and Reactivity: writing modules.
- Project structure: what goes in
nsh.json, assets, files.