
Build Nitro Modules
- 513 installs
- 152 repo stars
- Updated July 27, 2026
- margelo/react-native-skills
build-nitro-modules is an agent skill that scaffolds and implements React Native Nitro Modules with TypeScript and native bindings for developers who need high-performance custom native code in Expo and React Native apps
About
build-nitro-modules in margelo/react-native-skills helps developers create React Native Nitro Modules—the Margelo framework for writing fast native modules with TypeScript interfaces and C++ implementations. The skill guides agents through module scaffolding, autolinking configuration, type-safe JavaScript bindings, and bridging patterns that outperform legacy NativeModules for compute-heavy mobile features. Developers reach for build-nitro-modules when camera processing, cryptography, audio engines, or other hot paths cannot stay in JavaScript without frame drops. The workflow aligns with Margelo's Nitro architecture used across performance-focused React Native codebases and pairs with Expo prebuild or bare React Native projects. Triggers include Nitro module creation, native module migration, React Native performance optimization, or requests to expose C++ logic to TypeScript mobile code. Catalog description text is generic, but the skill slug and repo identify Nitro Modules as the concrete target framework.
- build-nitro-modules
- AI & Agent Building
- AI-coding skill
Build Nitro Modules by the numbers
- 513 all-time installs (skills.sh)
- +24 installs in the week ending Aug 2, 2026 (Skillselion tracking)
- Ranked #1,743 of 16,546 AI & Agent Building skills by installs in the Skillselion catalog
- Data as of Aug 2, 2026 (Skillselion catalog sync)
npx skills add https://github.com/margelo/react-native-skills --skill build-nitro-modulesAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 513 |
|---|---|
| repo stars | ★ 152 |
| Last updated | July 27, 2026 |
| Repository | margelo/react-native-skills ↗ |
How do you build React Native Nitro Modules?
Helps with ai & agent building tasks.
Who is it for?
React Native developers adding performance-critical native modules via Margelo Nitro instead of slow legacy NativeModules bridges.
Skip if: Pure JavaScript React Native screens with no native code requirements or teams targeting Flutter rather than React Native.
When should I use this skill?
User requests Nitro Modules, React Native native module creation, C++ mobile bindings, or migrating hot paths from JS to native code.
What you get
Nitro module package, TypeScript bindings, native C++ source, and autolinking configuration for React Native.
- Nitro module package
- TypeScript binding definitions
- Native C++ source files
Files
Build Nitro Modules
Overview
End-to-end skill for building a React Native Nitro Module: monorepo scaffolding via Nitrogen, TypeScript HybridObject spec authoring, native code generation, platform implementation (C++/Swift/Kotlin), example app wiring, and publish preparation.
Nitro Modules use a codegen pipeline (nitrogen) that reads .nitro.ts spec files and generates native C++/Swift/Kotlin boilerplate. You then fill in the implementation. This is fundamentally different from old-style turbo modules.
Generated files under nitrogen/generated/ are outputs. Change the .nitro.ts spec or native implementation source, then re-run nitrogen instead of manually editing generated files. These files can be committed to git, and many Nitro libraries do commit them, but the repo policy can choose otherwise. They must be included in the npm package so consumers can build the native library.
Pair With API Design
Use api-design first when shaping the public TypeScript, JavaScript, React, or React Native API. This skill adds the Nitro-specific constraints: HybridObject state, generated specs, native resource ownership, zero-copy data, threading, platform implementation, codegen, and real-device validation.
Let api-design own general public API rules and API freshness checks. In this skill, only add Nitro-specific freshness checks for mobile toolchain and generated-template decisions: verify current Nitro, React Native, Gradle, Xcode, Swift, Kotlin, NDK, and package-tooling docs/source before choosing versions, config fields, or native implementation details.
If the user is building a JS-only React or React Native library, do not apply this skill unless Nitro, HybridObjects, native modules, codegen, C++/Swift/Kotlin bindings, or react-native-nitro-modules are part of the task.
Pair with swift when implementing or reviewing Swift-backed HybridObjects, AVFoundation/session code, DispatchQueue usage, Swift concurrency, or thread-affine Swift state. Pair with kotlin when implementing or reviewing Kotlin-backed HybridObjects, Android threading, coroutines, Kotlin nullability, sealed result models, or Android service access. Pair with cpp when implementing or reviewing C++-backed HybridObjects, shared native engines, CMake, RAII ownership, or generated C++ spec bindings.
Repo and Release References
Load [repo-structure-and-workflow.md][repo-structure-and-workflow] only when creating a repo, reorganizing layout, adding examples/docs/CI, or changing workflow policy.
Load [release-it-publishing.md][release-it-publishing] only when setting up or reviewing bun release / release-it.
Nitro API Design Rules
- Prefer Nitro Modules over TurboModules or handwritten JSI for native module work. Nitro is usually faster and safer because it avoids many raw JSI lifetime, threading, and runtime-destruction hazards. Use raw JSI only when Nitro's Raw JSI Methods are required.
- Keep the root HybridObject default-constructible for autolinking. Create argument-dependent objects through factory methods.
- Use HybridObjects for native state: native resources, prewarmed engines, files, images, databases, sensor sessions, streams, and other stateful objects.
- If native setup is required, make the factory method async and resolve with a ready HybridObject, such as
createCameraSession(...): Promise<CameraSession>. - One JS-facing HybridObject spec can have multiple native concrete classes implementing the generated spec. Use this to hide backend strategies behind one TypeScript type, for example
CameraVideoOutputbacked by either a movie-file output or a video-data-output plus asset writer. Factories choose the native implementation and return the shared spec type. - Use a product/domain noun for the exported JS factory object, not the generated spec type name. For example, export
VisionCamera = createHybridObject<CameraFactory>('CameraFactory')orImages = createHybridObject<ImageFactory>('ImageFactory'). This avoids collisions withCameraFactory/ImageFactorytypes without mechanically lowercasing them or addingHybridprefixes. - Keep each HybridObject scoped to one purpose or lifecycle.
- Do not choose HybridObject boundaries only by domain noun. A one-shot command, an app-owned live session, a native view, and a long-lived engine are different contracts even when they belong to the same feature area.
- Split returned HybridObjects by stable semantic capability when their options, results, lifecycle, or future platform support differ. For example, a root
DataScannerFactorycan exposecreateBarcodeScanner()andcreateTextScanner()instead of one broad scanner whose text fields are nullable because Android currently supports only barcodes. - It is valid for a factory to report
isTextScannerAvailable: falseor rejectcreateTextScanner()on platforms that do not support that capability today. Future platform support should fill in the existing capability and flip availability to true, not require redesigning a fat HybridObject. - Platform-specific capabilities can still be first-class HybridObjects when the concept is stable. For example, an iOS-only
CameraObjectOutputcan extend a sharedCameraOutput, be marked@platform iOS, and reject atcreateObjectOutput(...)on Android instead of adding object-scanning nullable fields to every output. - Use factory methods to separate workflows and make returned HybridObjects stronger. If
createLiveScanner()returns a live scanner session, baseline session operations that the implementation controls should be guaranteed by that type. Backends that cannot provide the live workflow should fail creation or return a narrower object, not force the live object to expose dead methods, nullable baseline properties, or repeatedcan*checks. - Return configured handles to avoid stale state. If
configure(...)binds a device, output, stream, or native graph, return a new HybridObject handle for commands that only make sense for that configured resource. For example, aCameraSession.configure(...)method should returnCameraControllerhandles forsetZoom(...)andfocusTo(...)instead of putting those methods onCameraSessionwith implicit "current device" state. - Reconfiguration should replace or invalidate handles whose native target changed. Do not keep commands on a broad parent HybridObject when the command target depends on the last successful configuration.
- For native negotiation, model requested intent separately from resolved state. Use ranked constraints or preferences as input, then return or emit a resolved config HybridObject/struct that describes what the session actually selected. Provide an explicit resolver method when callers need to preview the result without creating or starting the native session.
- Use capability fields for workflow discovery, optional preferences, and genuinely variable support. Do not use capabilities to paper over an oversized HybridObject whose methods are unsupported during normal use on a supported backend.
- Treat HybridObjects as primary API objects. Each primary HybridObject gets its own
.nitro.tsfile. - Keep an inheritance family in one
.nitro.tsfile only when the file is named after the base HybridObject and child HybridObjects add few or no members, such asScannedCode,ScannedBarcode, andScannedQRCodeinScannedCode.nitro.ts. - Put named codegen types in their own
.tsfiles: string-literal unions/enums, structs/interfaces, option objects, event objects, callback option structs, and helper types. Nitro needs names for generated native structs and enum-like values. Import them into.nitro.tsspecs and re-export public types fromsrc/index.ts. - Inline simple function callbacks in method signatures, for example
addErrorListener(listener: (error: Error) => void): ListenerSubscription. Do not create one-off aliases such asScannerErrorListenerunless the function type is reused as a public concept across multiple APIs. - Group multiple helper types in one file only when they form one tightly coupled logical construct, such as
DynamicRangeplus the exact literal unions that define it. - Use HybridObject inheritance for shared native state plus specialized result shapes. Put shared properties such as IDs, bounds, raw values, formats, and value types on the base object instead of repeating them on every subtype.
- Use HybridObject inheritance for heterogeneous native result families. Example:
ScannedItemowns common state and methods, whileScannedBarcode,ScannedQRCode, andScannedFaceextend it with specialized properties. APIs can returnScannedItem[]; JS narrows by a discriminator property, and native code can accept the generated base spec when it only needs common behavior. - Do not model state families as one Nitro struct or HybridObject with every subtype field nullable. Use HybridObject inheritance, discriminated unions, or platform protocol/interface conformance so relationships such as
barcodeplusbarcodeTypeare compile-time safe. - Treat public Nitro HybridObjects as the imperative API. Export the generated Nitro API 1:1 when it is intended for users; do not add JS wrappers that pre-parse values, translate strings/enums, reshape options, inject hidden defaults, or call a different internal method shape than the
.nitro.tsspec exposes. - JS/TS layers are appropriate for intentionally higher-level APIs such as React hooks, React components, UI composition helpers, or when the HybridObject is only an internal implementation detail and does not match the user-facing mental model. In those cases, keep the boundary explicit: the wrapper is the public API and the Nitro object is internal.
- Autolink only public roots, factories, views, or global utilities that JS must construct directly. Other HybridObjects can be returned from factory methods and do not need their own
nitro.jsonautolinking entries. Do not autolink every concrete native implementation of the same JS-facing spec. - For native extension points, pair a JS-facing base HybridObject spec with a public native protocol/interface. The base spec lets JS pass the object through typed APIs; the native protocol/interface exposes platform-specific handles and behavior for first-party and third-party native code.
- When accepting an extensible HybridObject from JS, accept the generated base spec type, then cast to the native protocol/interface on the native side and throw a clear error if it does not conform. This keeps JS portable while native integrations stay strongly typed.
- Use Nitro structs for domain shapes, option groups, and same-type parameter clusters. Do not wrap unrelated hot-path values in a struct only to reduce argument count; Nitro eagerly converts structs, so unnecessary wrappers can be slower than explicit parameters.
- Remember that TypeScript optional fields become native optionals (
T?/std::optional<T>) in generated Swift, Kotlin, and C++. Use optional Nitro fields only when absence is part of the intended public/native contract, not because a JS wrapper will translate them away. - Prefer the Nitro method's generated structs to be the real public input shape. If defaults or resolved options are needed, model them explicitly in the spec/native implementation or provide a deliberately higher-level API above an internal HybridObject; do not add a casual JS normalization layer over a public HybridObject.
- Do not model high-volume native results, parsed payloads, images, buffers, or objects with many optional expensive fields as flat structs. Nitro structs are eagerly converted, so prefer stateful HybridObjects with lazy properties or methods for data the caller may never read.
- Decide explicitly whether each result should be a Nitro struct or HybridObject. A small immutable result with cheap scalar fields can be a struct. Use a HybridObject when the result owns native state, may expose lazy expensive data, needs zero-copy binary access, or is likely to grow behavior/methods.
- Use
ArrayBufferfor small or truly zero-copy native data access. For large media, photos, scans, model outputs, or byte payloads, return a HybridObject and expose lazy methods such astoArrayBuffer(),toBase64(), orsaveToTemporaryFile()instead of eagerly converting bytes into JS. - Do not choose
ArrayBufferonly because it is zero-copy. Usestringfor decoded text payloads andArrayBufferfor raw bytes, binary payloads, media, or opaque data where byte-level access is the API contract. - Keep raw native state behind HybridObjects when conversion cost matters. For example, a native barcode/photo/result object can expose
rawValue,bounds,format, or byte data lazily instead of converting every field for every detection. - For repeated events, prefer
addOn...Listener(callback): ListenerSubscriptionand let each subscription own its cleanup. This avoids one caller replacing another caller's callback through shared object state. - Listener subscriptions should be flat structs with a
remove: () => voidfunction field, not HybridObjects, unless they expose native state beyond cleanup. Call sites still usesubscription.remove(). - Listener cleanup should stop future emissions. It does not need to cancel a callback already selected by an in-flight native event snapshot, so do not add locks or complex subscription state only for that guarantee.
- Do not lock listener add, remove, and callback invocation as a default implementation pattern. Prefer one owner thread/queue or immutable snapshots; add a lock only for a proven shared mutable race that cannot be removed by ownership.
- Use a
setOn...(callback | undefined)-style API only for single hot-path callbacks owned by an object, where replacing or removing the callback is the natural operation. Thesetverb and docs must make the replacement semantics clear. - If the native API exposes only one delegate/callback but the JS API is a repeated event, prefer multiplexing internally and exposing additive listeners unless doing so would be unsafe or too expensive for the hot path.
- Use
Sync<(...) => ...>callbacks only for rare thread-bound hot paths that must synchronously execute on a specific JS runtime or worklet thread. - For Nitro Views, expose the raw
getHostComponentwrapper. Add React components or hooks only when they remove repeated setup code while staying layered over the same native objects and refs. - Follow
api-designfor naming, platform abstraction, sync/async boundaries, listener cleanup, errors, variants, TypeScript facades, and JSDoc contracts.
Nitro Native Implementation Rules
- Check Nitro's current minimum requirements before debugging weird build failures. Current Nitro docs list React Native 0.75+, Xcode 16.4+, Swift 5.9+, Android
compileSdkVersion34+, and NDK 27+; Nitro Views currently require React Native 0.78+ and the New Architecture. - Every native HybridObject implementation must implement or inherit from the generated
Hybrid*Specfor that platform. Never implement a standalone native class and expect Nitro to discover it. - Make native implementation classes
finalby default unless inheritance is genuinely required. This is especially true for Swift and Kotlin HybridObjects. - Keep exactly one top-level native implementation type per file. A
Hybrid*Factory.swift/.ktfile contains theHybrid*Factorytype and no other structs/classes/enums/interfaces/protocols. Helper sessions, coordinators, delegates, option adapters, and platform wrappers go in their own files. - Do not nest helper types inside the primary type to avoid file splitting. Give helper types their own named files.
- Treat native filenames as scope contracts. A file named after a HybridObject should focus on that HybridObject's implementation, not collect extension methods/properties, platform helpers, converters, delegates, protocols, or reusable utilities.
- Use line count as a review signal for native files: below roughly 300 lines is usually acceptable only after the one-type-per-file and no-helpers-in-factory rules are satisfied. If the size comes from helpers that could be named separately, split the file.
- Put native conversion helpers on the smallest meaningful type. If a native conversion only reads one generated struct or enum, put it on that type even when it returns multiple native values. Keep collection composition at the call site unless the collection conversion adds real behavior such as deduplication, validation, nonempty checks, batching, or error aggregation.
- Never put domain conversions on broad native/common receivers such as Kotlin
Intor SwiftInt. Use the domain type's companion, static factory, or initializer direction instead, such asBarcodeFormat.Companion.fromFormat(format: Int)orBarcodeFormat.from(format:). - Put every Kotlin extension
fun/val/varand Swift extension method/property in a separate named extension/converter file with internal/package visibility where possible. UseType+operation.ktnames such asBarcodeFormat+fromMLKitBarcodeFormat.ktfor Kotlin converters. There is no private/tiny/same-file exception forHybrid*files or other implementation files; this keeps code splitting, maintainability, and future review diffs clean. - A
Hybrid*Factory.ktorHybrid*.swiftfile must not contain barcode-format mappings, companion extensions, platform conversion utilities, or other extensions below the implementation. - Keep
Hybrid*Factoryfiles as orchestration only: resolve generated options, call validation/preflight helpers, create/start the native session or platform API, and return/reject the Promise. Do not define session/coordinator/delegate classes, native option adapter structs/classes, presenter lookup helpers, builder/config adapters, barcode mappings, permission switches, Info.plist/manifest checks, or capability checks in factory files. - Split native conversions into one focused extension file per source/target conversion, such as
Barcode+toScannedCode.kt,TargetBarcodeFormat+toMLKitFormat.kt, andBarcodeFormat+fromMLKitBarcodeFormat.kt. Do not hide a trivialmap { it.toX() }behind a concrete collection extension likeArray<TargetBarcodeFormat>.toMLKitFormats(). - Extract platform preflight checks out of
Hybrid*factories and implementation methods. Authorization-status switches, Info.plist/manifest key validation, permission checks, hardware capability checks, and service availability checks belong in focused helper/extension files; the HybridObject call site should read as a short guard or one-linetry/checkcall. - Do not replace inline preflight code with a broad
Utilsfile. Use small named files such asBundle+CameraUsageDescription.swift,AVCaptureDevice+CameraAuthorization.swift, or a focused Android permission/capability helper. - In Kotlin, annotate Android platform
Intconstants when the platform exposes a matching annotation or@IntDef, such as CameraX flash/capture/mirror mode annotations. Place the annotation according to its target on the function, return value, or parameter; verify whether it is a Java or Kotlin annotation before choosing the syntax. - When converting from an Android annotated
Int, annotate theformat: Intparameter on the domain factory/companion function when supported; this gives better IDE and lint feedback than a bareInt. - Validate invalid inputs and required unsupported behavior early. Do not reject cross-platform configuration just because the current native backend ignores an optional preference. If the operation still does its core job, ignore or degrade the preference and report support through capabilities or resolved state.
- Do not silently swallow real failures. Throw, reject a Promise, or emit through an explicit error callback/listener when the requested outcome cannot be delivered.
- Prefer Nitro/runtime errors or language-native exceptions that surface cleanly to JS. Avoid Objective-C-style
NSErrorpublic paths unless the generated API specifically requires it. - Do not add empty iOS bridging headers such as
Bridge.hto Nitro Modules. Nitro generates the Swift/C++ bridge it needs. Add Objective-C/Objective-C++ headers only when real handwritten Objective-C code requires them, and include only necessary files in the podspec. - For Android context access, use
NitroModules.applicationContextlazily and throw a clear error if it is unavailable. - Keep Nitro methods synchronous only for quick, deterministic, local work such as cheap object creation, cached metadata, or pure transforms. If the implementation is complex, heavy, fallible over time, native-async already, or crosses a thread/process boundary, make the Nitro API return
Promise<T>. - Use the native Promise helper that matches the platform threading model. In Swift, prefer
Promise.parallel(queue)for DispatchQueue-owned work such as AVFoundation/session queues, and usePromise.asynconly when wrapping Swiftasync/awaitor Task-based APIs end to end. For UIKit/VisionKit main-thread callback APIs, prefer a manual Promise with directDispatchQueue.main.asyncat the Nitro entry/callback boundary; do not useTask { @MainActor in ... }as a generic main-thread hop. - Avoid main/UI threads unless the native API requires them. Keep main-thread sections limited to UI/presentation/view mutation and run parsing, conversion, I/O, session negotiation, and CPU work on an owned queue/dispatcher/executor or native async API.
- Avoid manually creating or passing around
Promise<T>instances. PreferPromise.async,Promise.parallel,Promise.resolved, andPromise.rejectedbecause they complete exactly once through structured control flow. A manual Promise is only justified when bridging a native completion/delegate/callback API that cannot use the helpers; keep it in the smallest scope, do not pass it through arbitrary helpers, and guarantee every path resolves or rejects exactly once. - Before writing a manual Promise for callback/listener APIs, look for or add a general suspend/async adapter in a focused extension file. On Android, wrap Google
Task<T>once asTask+await.ktwithsuspendCancellableCoroutine, then usereturn Promise.async(scope) { task.await() }in the HybridObject method. - Do not hand-wire
addOnSuccessListener/addOnFailureListener/addOnCanceledListenerinsideHybrid*methods when a reusable callback-to-suspend adapter can express the API once. - Never hide a thread hop behind a generated property getter or setter. If native state can only be read or changed on a specific queue/thread, expose an async method, listener/event, or explicit lifecycle operation instead.
- Avoid chains of
Task,DispatchQueue, coroutine dispatcher, executor, and JS/Nitro runtime hops inside one operation. Pick a native owner queue/thread/dispatcher for each HybridObject or session and cross into it once at the Promise, lifecycle, or callback boundary. Repeated hops are a sign the HybridObject boundaries or lifecycle handles are wrong. - Never fix Nitro lifecycle, readiness, or race bugs with
setTimeout, sleeps, artificial delays, extra thread hops, or calling native methods twice. Model readiness with a Promise, listener/event, returned configured HybridObject, explicit state transition, or native completion callback. Use retries only for external hardware, OS service, remote service, or network uncertainty, with bounded/cancellable/idempotent behavior. - Implement
memorySizefor HybridObjects that own native resources or large allocations so the JS VM can collect them under memory pressure. - For Nitro Views, implement
prepareForRecyclewhen the view owns state that should be reset before reuse. - Mix C++ HybridObjects with Swift/Kotlin HybridObjects in one library. Use C++ for shared or hot code, such as OpenCV/frame processing/storage engines, and Swift/Kotlin for platform services, permissions, file paths, camera/session APIs, and OS integration.
- C++ HybridObjects can accept Swift/Kotlin-implemented HybridObjects and call their generated C++ spec API. Example: a C++
StorageFactorycan accept a Swift/KotlinPlatformContextand callgetTemporaryDirectory()orwriteFile(...)through the generated C++ interface. C++ can access only the public spec API, not private Swift/Kotlin fields. - Do not rely on Swift/Kotlin calling into C++-implemented HybridObjects unless current Nitrogen support has been verified for that direction.
Nitro Testing Rules
- Prefer behavior tests over type-shape tests. Nitrogen already enforces specs at compile time, so tests should cover real feature behavior, inputs, settings, failure paths, and order-of-execution cases.
- Use
react-native-harnesswhen available for end-to-end testing in a real React Native environment. For native-heavy libraries, prefer real-device or CI device-farm coverage for the API surface that depends on hardware or OS behavior. - In GitHub Actions, prefer harness E2E jobs over standalone native unit tests for APIs that are only public through React Native. Add Kotlin JUnit, XCTest, or other native-only tests only when the package has a native target usable outside React Native or RN harness coverage cannot exercise the behavior.
Ask First — Before Doing Anything
First, determine what the user wants to do:
"Are you creating a new Nitro Module library from scratch, or adding a new HybridObject to an existing library?"
---
If creating a new library — ask all of these before any command:
1. Library name — What should the library be called? (e.g. react-native-math) 2. Monorepo with `packages/` folder — Should the library live in packages/<name> inside a monorepo? (Strongly recommended — default: yes) 3. Example app — Should an example app be created to test the module, and where should it live? (Recommended — default: yes; `apps/example` when multiple examples are needed or likely, `example` only for a small single-example repo that should stay close to generated RN config) 4. Native languages — Which platforms and languages?
- iOS:
swift(default) orcpp - Android:
kotlin(default) orcpp - Cross-platform C++ only: both
cpp
5. Module purpose — Briefly describe what the module does so the correct spec methods can be designed
Do not proceed past Step 1 of the build sequence until all five questions are answered.
If adding a HybridObject to an existing library — ask only:
1. HybridObject name — What should the new HybridObject be called? (e.g. Camera, Crypto) 2. Native languages — iOS: swift or cpp? Android: kotlin or cpp? 3. Purpose — What does this HybridObject do?
Then skip directly to [spec-hybrid-object.md][spec-hybrid-object] (write the spec), [spec-nitro-json.md][spec-nitro-json] (add autolinking entry), [native-nitrogen-codegen.md][native-nitrogen-codegen] (re-run nitrogen), and the relevant native implementation file. Skip all setup, monorepo, and example app steps.
Typical Build Sequence
# 0. Work on a separate branch; open a draft PR after the first useful commit
# 1. Scaffold
bunx nitrogen@latest init react-native-math
# 2. Run codegen (from package folder after writing spec + nitro.json)
cd packages/react-native-math && bunx nitrogen
# 3. Create example app
bunx @react-native-community/cli@latest init --skip-install MathExample
mkdir -p apps && mv MathExample apps/example
# Alternative: mv MathExample example
# 4. Install and test
cd apps/example && bun add ../../packages/react-native-math
bun add react-native-nitro-modules@<same-version-as-package>
bun example android
bun example iosFull step-by-step references below.
When to Apply
Reference these guidelines when:
- Creating any new React Native native module using the Nitro framework
- Checking Nitro minimum platform requirements
- Verifying current Nitro, React Native, and native-toolchain requirements before making implementation decisions
- Designing or reviewing the public API shape of a Nitro-backed library
- Deciding whether an API should be static, instance-based, sync, async, callback-based, or resource-backed
- Writing HybridObject TypeScript specs (
*.nitro.tsfiles) - Running Nitrogen codegen and implementing generated interfaces
- Setting up a monorepo example app for a Nitro library
- Choosing repository layout, root cleanliness, shared config placement, and CI shape
- Establishing branch, draft PR, and squash-merge workflow for a Nitro library
- Configuring Android Gradle paths for a monorepo structure
- Debugging autolinking failures or missing generated files
- Preparing a Nitro module package for npm publishing
- Setting up one-command releases with
release-itandbun release
Priority-Ordered Guidelines
| Priority | Category | Impact | Reference |
|---|---|---|---|
| 0 | General public API shape | CRITICAL | api-design |
| 0 | Nitro API constraints | CRITICAL | This SKILL.md |
| 1 | Repo structure and workflow | HIGH | [repo-structure-and-workflow.md][repo-structure-and-workflow] |
| 2 | Nitrogen scaffold | CRITICAL | [setup-monorepo-init.md][setup-monorepo-init] |
| 3 | HybridObject spec | CRITICAL | [spec-hybrid-object.md][spec-hybrid-object] |
| 4 | nitro.json autolinking | CRITICAL | [spec-nitro-json.md][spec-nitro-json] |
| 5 | Nitrogen codegen | HIGH | [native-nitrogen-codegen.md][native-nitrogen-codegen] |
| 6 | C++ implementation | HIGH | [native-implement-cpp.md][native-implement-cpp] |
| 7 | Kotlin implementation | HIGH | [native-implement-kotlin.md][native-implement-kotlin] |
| 8 | Swift implementation | HIGH | [native-implement-swift.md][native-implement-swift] |
| 9 | Example app setup (if requested) | HIGH | [example-app-setup.md][example-app-setup] |
| 10 | Android Gradle paths (if example app) | HIGH | [example-android-config.md][example-android-config] |
| 11 | Metro + install + test (if example app) | HIGH | [example-metro-install.md][example-metro-install] |
| 12 | npm publish readiness | MEDIUM | [spec-package-publish.md][spec-package-publish] |
| 13 | release-it publishing | MEDIUM | [release-it-publishing.md][release-it-publishing] |
| 14 | VisionCamera-style full library patterns | MEDIUM | [vision-camera-golden-standard.md][vision-camera-golden-standard] |
Quick Reference
Minimum HybridObject Spec (src/specs/Math.nitro.ts)
import type { HybridObject } from 'react-native-nitro-modules'
export interface Math extends HybridObject<{ ios: 'swift'; android: 'kotlin' }> {
add(a: number, b: number): number
}Minimum Runtime + Type Exports (src/index.ts)
import { NitroModules } from 'react-native-nitro-modules'
import type { Math } from './specs/Math.nitro'
export const math = NitroModules.createHybridObject<Math>('Math')
export type { Math } from './specs/Math.nitro'Package entry points such as src/index.ts, index.ts, index.js, and index.tsx must stay barrels. They may contain direct re-exports and a one-line Nitro root export such as export const camera = NitroModules.createHybridObject<Camera>('Camera'), but no actual implementation logic, functions, classes, hooks, components, branching, side effects, or helper definitions. Move real definitions to focused files and re-export them.
For JS/TS source in Nitro packages, keep absence and mutation explicit: never use void 0 instead of undefined, and never use logical assignment operators such as ??=, ||=, or &&=. Prefer an explicit if block or direct assignment that makes fallback behavior visible.
Minimum nitro.json
{
"$schema": "https://nitro.margelo.com/nitro.schema.json",
"cxxNamespace": ["math"],
"ios": { "iosModuleName": "NitroMath" },
"android": {
"androidNamespace": ["math"],
"androidCxxLibName": "NitroMath"
},
"autolinking": {
"Math": {
"ios": {
"language": "swift",
"implementationClassName": "HybridMath"
},
"android": {
"language": "kotlin",
"implementationClassName": "HybridMath"
}
}
}
}Root package.json Scripts
{
"scripts": {
"specs": "bun --cwd packages/react-native-math run specs",
"example": "bun --cwd apps/example"
}
}Run: bun example android, bun example ios, bun specs
References
| File | Description |
|---|---|
| [repo-structure-and-workflow.md][repo-structure-and-workflow] | Root layout, README/docs, packages/apps/config/scripts, CI, branch, draft PR, and squash-merge workflow |
| [setup-monorepo-init.md][setup-monorepo-init] | Collecting scaffold inputs and running nitrogen init |
| [spec-hybrid-object.md][spec-hybrid-object] | Writing *.nitro.ts specs and exporting HybridObjects |
| [spec-nitro-json.md][spec-nitro-json] | nitro.json all fields, autolinking, namespace configuration |
| [native-nitrogen-codegen.md][native-nitrogen-codegen] | Running Nitrogen and verifying generated files |
| [native-implement-cpp.md][native-implement-cpp] | Implementing HybridObjects in C++ |
| [native-implement-kotlin.md][native-implement-kotlin] | Implementing HybridObjects in Kotlin (Android) |
| [native-implement-swift.md][native-implement-swift] | Implementing HybridObjects in Swift (iOS) |
| [example-app-setup.md][example-app-setup] | RN CLI example app init, workspace wiring, version alignment |
| [example-android-config.md][example-android-config] | settings.gradle and build.gradle monorepo path fixes |
| [example-metro-install.md][example-metro-install] | Metro watchFolders, library install, App.tsx usage, test runs |
| [spec-package-publish.md][spec-package-publish] | package.json author, files field, and npm publish readiness |
| [release-it-publishing.md][release-it-publishing] | One-command releases with release-it and bun release |
| [vision-camera-golden-standard.md][vision-camera-golden-standard] | Package layout, API layering, Nitro object modeling, and publishing patterns inspired by VisionCamera |
Problem → Skill Mapping
| Problem | Reference | Action |
|---|---|---|
| Need to design the public API first | api-design + this SKILL.md | Shape the TS/React API, then apply Nitro constraints |
| Need latest general APIs | api-design | Check official docs, release notes, source repos, package metadata, or llms-full.txt before deciding |
| Need a recommended repo structure | [repo-structure-and-workflow.md][repo-structure-and-workflow] | Use main, a strong README, packages/, apps/ or example/, optional Fumadocs, scripts/, config/, and .github/workflows/ |
| Unsure static module vs instance API | This SKILL.md | Prefer HybridObjects for native state, resources, prewarming, and zero-copy data |
| Don't know where to start | [setup-monorepo-init.md][setup-monorepo-init] | Scaffold with nitrogen init |
| Spec file syntax error | [spec-hybrid-object.md][spec-hybrid-object] | Fix *.nitro.ts interface |
| Autolinking not working | [spec-nitro-json.md][spec-nitro-json] | Check nitro.json autolinking block |
| Nitrogen generates no files | [native-nitrogen-codegen.md][native-nitrogen-codegen] | Verify spec file extension and run command from right dir |
| C++ types unclear | [native-implement-cpp.md][native-implement-cpp] | Follow type reference links to canonical examples |
| Kotlin compilation error | [native-implement-kotlin.md][native-implement-kotlin] | Check annotations and override modifiers |
| Swift compilation error | [native-implement-swift.md][native-implement-swift] | Check class inheritance and property signatures |
| Example app won't build (Android) | [example-android-config.md][example-android-config] | Fix Gradle monorepo path configuration |
| Metro can't resolve library | [example-metro-install.md][example-metro-install] | Add watchFolders to metro.config.js |
| Version mismatch between example and package | [example-app-setup.md][example-app-setup] | Align react-native versions across workspaces |
| Package missing files on npm | [spec-package-publish.md][spec-package-publish] | Fix files field in package.json |
| Need one-command releases | [release-it-publishing.md][release-it-publishing] | Configure release-it behind bun release |
| Need a full-featured library structure | [vision-camera-golden-standard.md][vision-camera-golden-standard] | Use the VisionCamera-inspired package, API, hooks, views, and Nitro object model |
[repo-structure-and-workflow]: references/repo-structure-and-workflow.md [setup-monorepo-init]: references/setup-monorepo-init.md [spec-hybrid-object]: references/spec-hybrid-object.md [spec-nitro-json]: references/spec-nitro-json.md [native-nitrogen-codegen]: references/native-nitrogen-codegen.md [native-implement-cpp]: references/native-implement-cpp.md [native-implement-kotlin]: references/native-implement-kotlin.md [native-implement-swift]: references/native-implement-swift.md [example-app-setup]: references/example-app-setup.md [example-android-config]: references/example-android-config.md [example-metro-install]: references/example-metro-install.md [spec-package-publish]: references/spec-package-publish.md [release-it-publishing]: references/release-it-publishing.md [vision-camera-golden-standard]: references/vision-camera-golden-standard.md
Skill: Android Gradle Configuration for Nested Example Apps
Covers Steps 14–15: fixing settings.gradle and app/build.gradle when the example app layout means React Native's generated Android paths no longer point to the correct node_modules.
Quick Config
Only apply this when the example app is nested under apps/example and root node_modules lives at the workspace root. A shallower example/ app or standalone app layout may work with the generated React Native config; if the generated paths resolve, leave them alone.
Treat these edits as narrow layout adaptations, not a place to accumulate workaround plumbing. Prefer the official React Native Gradle plugin APIs and generated template shape; if Android needs more than path-depth corrections, investigate the package layout, workspace install, autolinking, or upstream issue first.
Two files need path corrections for the apps/example layout:
`apps/example/android/settings.gradle` — fix React Native Gradle plugin paths:
pluginManagement { includeBuild("../../../node_modules/@react-native/gradle-plugin") }
plugins { id("com.facebook.react.settings") }
extensions.configure(com.facebook.react.ReactSettingsExtension) { ex -> ex.autolinkLibrariesFromCommand() }
rootProject.name = 'MathExample'
include ':app'
includeBuild("../../../node_modules/@react-native/gradle-plugin")Replace the generated settings.gradle block with this. Do not keep older example/android paths, includeBuild("../../node_modules/..."), native_modules.gradle, or applyNativeModulesSettingsGradle(...) lines.
`apps/example/android/app/build.gradle` — fix react{} block paths:
react {
reactNativeDir = file("../../../../node_modules/react-native")
codegenDir = file("../../../../node_modules/@react-native/codegen")
cliFile = file("../../../../node_modules/react-native/cli.js")
hermesCommand = "$rootDir/../../../node_modules/hermes-compiler/hermesc/%OS-BIN%/hermesc"
autolinkLibrariesWithApp()
}When to Use
- When an example app is nested deeply enough that React Native's generated Android paths do not resolve
- Whenever Android builds fail with
FileNotFoundExceptionfor gradle-plugin or react-native paths - When using the VisionCamera-style
apps/<name>layout with root-level workspace dependencies - Do not use this just because the repo is a monorepo. Verify the generated paths first; shallower
example/or standalone example apps can work out of the box.
Prerequisites
- Example app created and moved to
apps/example/folder, or another nested folder whose generated paths do not resolve - Root
node_modules/installed at the monorepo root
Path Depth Reference
For a monorepo structured as:
<root>/ ← node_modules/ lives here
apps/
example/
android/
settings.gradle ← 3 levels up to root: ../../../
app/
build.gradle ← 4 levels up to root: ../../../../$rootDir in Gradle refers to <root>/apps/example/android/. So $rootDir/../../.. reaches the monorepo root.
If the app lives one level shallower at <root>/example/android/, do not blindly apply the apps/example paths. First check whether the generated config already resolves node_modules. If it does, keep it. If root workspace dependencies require a manual path, count from example/android/ to the workspace root instead.
Step-by-Step
1. Fix apps/example/android/settings.gradle
Open apps/example/android/settings.gradle and make the React Native Gradle plugin paths point to the monorepo root:
pluginManagement { includeBuild("../../../node_modules/@react-native/gradle-plugin") }
plugins { id("com.facebook.react.settings") }
extensions.configure(com.facebook.react.ReactSettingsExtension) { ex -> ex.autolinkLibrariesFromCommand() }
rootProject.name = 'MathExample'
include ':app'
includeBuild('../../../node_modules/@react-native/gradle-plugin')This is a replacement for the generated React Native settings file, not an append. If the file still contains includeBuild("../../node_modules/@react-native/gradle-plugin") or legacy native_modules.gradle lines, remove them.
2. Fix apps/example/android/app/build.gradle
Open apps/example/android/app/build.gradle and find the react { } block. Replace with:
react {
/* Folders */
// Root of your project (where root package.json lives)
// root = file("../../../")
// react-native NPM package location
reactNativeDir = file("../../../../node_modules/react-native")
// @react-native/codegen package location
codegenDir = file("../../../../node_modules/@react-native/codegen")
// react-native CLI entrypoint
cliFile = file("../../../../node_modules/react-native/cli.js")
/* Hermes */
// hermesc is 3 levels up from $rootDir (which is apps/example/android/)
hermesCommand = "$rootDir/../../../node_modules/hermes-compiler/hermesc/%OS-BIN%/hermesc"
/* Autolinking — keep this */
autolinkLibrariesWithApp()
}3. Verify the paths exist
# Verify react-native is at root
ls ../../../node_modules/react-native/package.json
# Verify codegen
ls ../../../node_modules/@react-native/codegen/package.json
# Verify hermes-compiler
ls ../../../node_modules/hermes-compiler/Run from apps/example/android/ to match the relative path perspective.
4. Build to verify
cd apps/example
bun android
# or from root:
bun example androidCode Examples
Complete settings.gradle (corrected)
pluginManagement {
includeBuild("../../../node_modules/@react-native/gradle-plugin")
}
plugins { id("com.facebook.react.settings") }
extensions.configure(com.facebook.react.ReactSettingsExtension) { ex -> ex.autolinkLibrariesFromCommand() }
rootProject.name = 'MathExample'
include ':app'
includeBuild('../../../node_modules/@react-native/gradle-plugin')Complete corrected react {} block in build.gradle
react {
reactNativeDir = file("../../../../node_modules/react-native")
codegenDir = file("../../../../node_modules/@react-native/codegen")
cliFile = file("../../../../node_modules/react-native/cli.js")
hermesCommand = "$rootDir/../../../node_modules/hermes-compiler/hermesc/%OS-BIN%/hermesc"
autolinkLibrariesWithApp()
}Common Pitfalls
- Wrong path depth — Count the directory levels from the file to the monorepo root carefully
- `$rootDir` is not the monorepo root — In Gradle,
$rootDirisapps/example/android/, not the monorepo root - Forgetting `autolinkLibrariesWithApp()` — This must stay in the
react {}block for autolinking to work - Editing both files — Both
settings.gradleANDapp/build.gradleneed to be updated; missing one causes different failures - Hermes path uses `$rootDir` as base —
$rootDir/../../../node_modules/hermes-compiler/...goes fromapps/example/android/up 3 levels to reach monorepo root
Related Skills
- example-app-setup.md — Create the example app first
- example-metro-install.md — Next: configure Metro and run the example
Skill: Creating and Wiring the Example App
Covers Steps 11–13: creating the React Native example app with RN CLI, adding it to the monorepo workspace, and aligning dependency versions.
Quick Commands
# Create example app (from monorepo root)
bunx @react-native-community/cli@latest init --skip-install MathExample
# Move to apps/example/ when multiple examples are needed or likely, or example/ for a shallower layout
mkdir -p apps
mv MathExample apps/example
# Alternative: mv MathExample example
# Add to workspace, then install
bun installWhen to Use
- Only proceed with this file if the user confirmed they want an example app (asked in the initial questions)
- After native implementation is complete and you need a testable example app
- When setting up the monorepo for the first time
Prerequisites
- Library package in
packages/<name>is scaffolded and implemented - Root
package.jsonhasworkspacesfield
Step-by-Step
1. Create the example app
Run from the monorepo root:
bunx @react-native-community/cli@latest init --skip-install MathExample- Use
--skip-installto avoid installing into the wrong directory - Prefer
bunx; fall back tonpxonly if the React Native CLI does not run correctly through Bun - Name the app based on the library (e.g.
MathExample,CameraExample) - See RN CLI docs for additional options
2. Choose the example app location
The scaffold creates a folder named MathExample. Prefer the VisionCamera-style apps/example layout when multiple examples are needed or likely, including optional native dependencies, feature variants, or separate integration demos:
mkdir -p apps
mv MathExample apps/exampleFor intentionally small single-example libraries, a shallower example/ app is also valid and can keep more React Native generated config working out of the box:
mv MathExample exampleChoose one layout. The larger monorepo layout looks like:
.
├── packages/
│ └── react-native-math/
├── apps/
│ └── example/ ← example app lives here
│ ├── android/
│ ├── ios/
│ ├── App.tsx
│ └── package.json
└── package.json ← root workspaceKeep the app close to the official React Native template. Prefer generated config, official APIs, and template-supported extension points. Avoid custom Metro, Gradle, Podfile, native project, or postinstall plumbing unless the chosen repo layout truly requires it; if it does, make the smallest targeted change and look for the root cause before adding another workaround.
3. Add the example app to root workspaces
In the root package.json:
{
"workspaces": [
"packages/*",
"apps/*"
]
}If the app lives at example/, use:
{
"workspaces": [
"packages/*",
"example"
]
}4. Align React Native versions
This is critical — two different versions of react-native in the same monorepo causes cryptic build failures.
Check the example app's RN version:
cat apps/example/package.json | grep '"react-native"'
# or, for the shallower layout:
cat example/package.json | grep '"react-native"'Open packages/react-native-math/package.json and ensure:
{
"peerDependencies": {
"react": "*",
"react-native": "*",
"react-native-nitro-modules": "*"
},
"devDependencies": {
"react": "<same-as-example>",
"react-native": "<same-as-example>"
}
}- If the package's
devDependenciesversion is lower than the example, upgrade it to match - Also align:
react,@babel/core,metro,@react-native/metro-config - There must be zero duplicate versions of any shared library
5. Install from root
bun installCode Examples
Root package.json (after setup)
{
"name": "react-native-math-root",
"private": true,
"workspaces": [
"packages/*",
"apps/*"
],
"scripts": {
"specs": "bun --cwd packages/react-native-math run specs",
"example": "bun --cwd apps/example"
}
}For a shallower example/ app, change the workspace to "example" and the script to "example": "bun --cwd example".
Package package.json aligned versions
{
"name": "react-native-math",
"peerDependencies": {
"react": "*",
"react-native": "*",
"react-native-nitro-modules": "*"
},
"devDependencies": {
"react": "<same-as-example>",
"react-native": "<same-as-example>",
"react-native-nitro-modules": "<same-as-package>"
}
}Version check command
# Check for duplicate react-native installs
find . -name "package.json" -not -path "*/node_modules/*" | xargs grep '"react-native"' | grep -v workspaceCommon Pitfalls
- Forgetting `--skip-install` — Without it, npm/yarn installs from the wrong directory; use
--skip-installthenbun installfrom root - Two RN versions — Even a minor version mismatch causes cryptic
Invariant Violationerrors at runtime - Workspace path mismatch — The app folder must match the workspace glob, either
apps/*forapps/exampleorexamplefor the shallower layout - Running `pod install` before workspace is set up — Do
bun installfrom root first, thenpod install
Related Skills
- example-android-config.md — Next: verify Android Gradle paths; fix them only if the chosen layout requires it
- example-metro-install.md — Next: configure Metro and install the library
Skill: Metro Config, Library Install, and Running the Example
Covers Steps 16–20: configuring Metro watchFolders, installing the library in the example app, implementing App.tsx, adding root scripts, and running on Android and iOS.
Quick Commands
# Configure Metro if it cannot resolve the package from the chosen example app layout
# Install library in the example app.
# apps/example layout:
cd apps/example
bun add ../../packages/react-native-math
bun add react-native-nitro-modules@<same-version-as-package>
# example/ layout:
# cd example
# bun add ../packages/react-native-math
# bun add react-native-nitro-modules@<same-version-as-package>
# iOS: install pods
cd ios && pod install && cd ..
# Run
bun example android
bun example iosWhen to Use
- After Android Gradle paths are verified or configured
- When Metro can't resolve the local library package
- When setting up the example app to test the module
Prerequisites
- Example app created in
apps/example/,example/, or another chosen layout - Android Gradle paths verified, or corrected only if the chosen layout requires it
- Library package is in
packages/<name>
Step-by-Step
1. Configure Metro watchFolders
Open apps/example/metro.config.js and add the monorepo root to watchFolders:
const { getDefaultConfig, mergeConfig } = require('@react-native/metro-config');
const path = require('node:path');
const root = path.resolve(__dirname, '..', '..');
const config = {
watchFolders: [root],
};
module.exports = mergeConfig(getDefaultConfig(__dirname), config);Without watchFolders, Metro only watches the example app directory and can't find your library in packages/.
For a shallower example/ layout, the monorepo root is one level up instead:
const root = path.resolve(__dirname, '..');2. Install the library
cd apps/example
bun add ../../packages/react-native-mathThis creates a symlink from apps/example/node_modules/react-native-math to packages/react-native-math.
For a shallower example/ layout, use:
cd example
bun add ../packages/react-native-math3. Install react-native-nitro-modules at a pinned version
The version must match what packages/react-native-math uses:
# Check what version the package uses
cat ../../packages/react-native-math/package.json | grep nitro-modules
# from example/: cat ../packages/react-native-math/package.json | grep nitro-modules
# Install the same version in example
bun add react-native-nitro-modules@<same-version-as-package>Having two different versions of react-native-nitro-modules can cause runtime/build crashes.
4. Install iOS pods
cd apps/example/ios
pod install
cd ../../..Run this after any new native dependency is added.
5. Implement App.tsx
Replace the default apps/example/App.tsx with a test implementation:
import React, { useState } from 'react';
import { Button, StyleSheet, Text, View } from 'react-native';
import { math } from 'react-native-math';
function App(): React.JSX.Element {
const [result, setResult] = useState<number | null>(null);
return (
<View style={styles.container}>
<Text style={styles.title}>Math Module Test</Text>
<Button
title="Add 5 + 7"
onPress={() => setResult(math.add(5, 7))}
/>
{result !== null && (
<Text style={styles.result}>Result: {result}</Text>
)}
</View>
);
}
const styles = StyleSheet.create({
container: { flex: 1, justifyContent: 'center', alignItems: 'center' },
title: { fontSize: 24, fontWeight: 'bold', marginBottom: 20 },
result: { fontSize: 32, marginTop: 20 },
});
export default App;6. Add root scripts
In the monorepo root package.json:
{
"scripts": {
"specs": "bun --cwd packages/react-native-math run specs",
"example": "bun --cwd apps/example"
}
}This enables:
bun example android— runs the example on Androidbun example ios— runs the example on iOSbun example start— starts the Metro bundler
7. Run on Android
bun example android
# or directly:
cd apps/example && bun androidWatch for errors in logcat if the build succeeds but the app crashes.
8. Run on iOS
bun example ios
# or directly:
cd apps/example && bun iosCode Examples
apps/example/metro.config.js (complete)
const { getDefaultConfig, mergeConfig } = require('@react-native/metro-config');
const path = require('node:path');
const root = path.resolve(__dirname, '..', '..');
/**
* Metro configuration
* https://reactnative.dev/docs/metro
*
* @type {import('@react-native/metro-config').MetroConfig}
*/
const config = {
watchFolders: [root],
};
module.exports = mergeConfig(getDefaultConfig(__dirname), config);Root package.json scripts (complete)
{
"scripts": {
"specs": "bun --cwd packages/react-native-math run specs",
"example": "bun --cwd apps/example",
"example:android": "bun --cwd apps/example android",
"example:ios": "bun --cwd apps/example ios"
}
}Async method usage in App.tsx
const [fibResult, setFibResult] = useState<number | null>(null);
const calculateFib = async () => {
const result = await math.calculateFibonacci(10);
setFibResult(result);
};Common Pitfalls
- Missing `watchFolders` — Metro won't find the library package; add it to
metro.config.js - `react-native-nitro-modules` version mismatch — Install the exact same version in example as in the package
- Forgetting `pod install` — iOS won't pick up new native libraries without running
pod install - Only testing JS bundling — Nitro code needs a native build. Test on an iOS simulator/device or Android emulator/device, not only Metro, web, or a JS-only test runner.
- Metro cache stale — If you change the lib and Metro doesn't pick it up, run
bun example start --reset-cache
Related Skills
- example-app-setup.md — Create the example app first
- example-android-config.md — Verify Android Gradle paths, and fix them only when the chosen layout requires it
- spec-package-publish.md — Final step: prepare for npm publishing
Skill: Implementing HybridObjects in C++
Covers Steps 8–9 (C++ path): creating the C++ implementation class that inherits from the Nitrogen-generated spec.
Quick Pattern
Implement in a separate file that inherits from the generated spec:
// cpp/HybridMath.hpp
#pragma once
#include "HybridMathSpec.hpp"
namespace margelo::nitro::math {
class HybridMath final : public HybridMathSpec {
public:
HybridMath() : HybridObject(TAG) {}
double add(double a, double b) override;
};
}When to Use
- When the spec uses
{ ios: 'cpp'; android: 'cpp' }(shared C++ implementation) - When implementing platform-agnostic logic that runs on both iOS and Android
- When performance or code-sharing across platforms is critical
- When C++ code needs to call the public generated spec API of a Swift/Kotlin-implemented HybridObject passed in from TypeScript
Prerequisites
- Nitrogen has generated
HybridMathSpec.hpp, usually innitrogen/generated/shared/c++/ nitro.jsonhas"all": { "language": "c++", "implementationClassName": "HybridMath" }in the autolinking block
Mixed-Language Object Graphs
C++ HybridObjects can accept HybridObjects implemented in Swift/Kotlin and call their generated C++ spec API. This lets C++ engines use platform services without hand-written Swift/JNI bridges.
Example spec shape:
export interface PlatformContext
extends HybridObject<{ ios: 'swift'; android: 'kotlin' }> {
getTemporaryDirectory(): string
writeFile(content: ArrayBuffer, path: string): Promise<void>
}
export interface StorageFactory
extends HybridObject<{ ios: 'c++'; android: 'c++' }> {
createStorage(context: PlatformContext): Storage
}The generated C++ signature for createStorage(...) receives the generated C++ spec type for PlatformContext. C++ can call public methods like getTemporaryDirectory() and writeFile(...); it cannot access private Swift/Kotlin implementation fields.
Use this for C++ OpenCV/frame processing, storage engines, ML/image/audio pipelines, or compression code that needs platform file paths, permissions, OS handles, or persistence implemented in Swift/Kotlin.
Do not assume Swift/Kotlin can directly consume C++-implemented HybridObjects unless current Nitrogen support has been verified for that direction.
Step-by-Step
1. Locate the generated spec
nitrogen/generated/shared/c++/HybridMathSpec.hpp ← abstract base class2. Create the implementation header
touch cpp/HybridMath.hpp// cpp/HybridMath.hpp
#pragma once
#include "HybridMathSpec.hpp"
namespace margelo::nitro::math {
class HybridMath final : public HybridMathSpec {
public:
HybridMath() : HybridObject(TAG) {}
public:
// Implement all pure virtual methods from the generated spec
double add(double a, double b) override;
double subtract(double a, double b) override;
std::shared_ptr<Promise<double>> calculateFibonacci(double n) override;
// Properties
double getPi() override;
double getPrecision() override;
void setPrecision(double precision) override;
private:
double _precision = 6.0;
public:
inline static const char* TAG = "Math";
};
} // namespace margelo::nitro::math3. Create the implementation source file
touch cpp/HybridMath.cpp// cpp/HybridMath.cpp
#include "HybridMath.hpp"
namespace margelo::nitro::math {
double HybridMath::add(double a, double b) {
return a + b;
}
double HybridMath::subtract(double a, double b) {
return a - b;
}
std::shared_ptr<Promise<double>> HybridMath::calculateFibonacci(double n) {
return Promise<double>::async([n]() -> double {
if (n <= 1) return n;
double a = 0, b = 1;
for (int i = 2; i <= n; i++) {
double temp = a + b;
a = b;
b = temp;
}
return b;
});
}
double HybridMath::getPi() {
return M_PI;
}
double HybridMath::getPrecision() {
return _precision;
}
void HybridMath::setPrecision(double precision) {
_precision = precision;
}
} // namespace margelo::nitro::math4. Register in CMakeLists.txt
Add the implementation file to android/CMakeLists.txt:
add_library(
NitroMath
SHARED
../nitrogen/generated/shared/c++/HybridMathSpec.cpp
../cpp/HybridMath.cpp # ← add this
)Current Nitro projects usually include generated C++ through nitrogen/generated/android/<ModuleName>+autolinking.cmake; prefer that generated CMake include when present instead of manually listing each generated source.
5. Verify using canonical type reference
For any type uncertainty, consult the canonical C++ test implementation: HybridTestObjectCpp.cpp
Code Examples
Type reference table
| TypeScript | C++ Type | Notes |
|---|---|---|
number | double | Always double, never float |
string | const std::string& (param) / std::string (return) | |
boolean | bool | |
bigint (signed) | int64_t | |
bigint (unsigned) | uint64_t | |
T[] | std::vector<T> | e.g. std::vector<double>, std::vector<std::string> |
Promise<T> | std::shared_ptr<Promise<T>> | Use Promise<T>::async(lambda) |
Promise<void> | std::shared_ptr<Promise<void>> | |
| `T \ | undefined` | std::optional<T> |
| `T \ | U` | std::variant<T, U> |
(x: T) => void | std::function<void(T)> | |
() => T | std::function<T()> | |
ArrayBuffer | std::shared_ptr<ArrayBuffer> | |
AnyMap / Record | std::shared_ptr<AnyMap> | Nitro's generic map type |
Record<string, T> | std::unordered_map<std::string, T> | For simple typed maps |
HybridObject | std::shared_ptr<HybridSpec> | e.g. std::shared_ptr<HybridMathSpec> |
null / NullType | NullType | Use nitro::null constant |
Date | std::chrono::system_clock::time_point |
Throwing errors
double HybridMath::divide(double a, double b) {
if (b == 0) {
throw std::runtime_error("Division by zero!");
}
return a / b;
}Callback parameter
void HybridMath::compute(double input, std::function<void(double)> onResult) {
double result = input * 2;
onResult(result);
}Keep quick, deterministic, local work synchronous. Do not introduce Promise, executors, or callback plumbing for simple value construction, cached metadata, or pure transforms. Use an owned executor/thread or Nitro Promise only for heavy work, I/O, platform async APIs, or work that must not block the caller.
Avoid platform main/UI threads unless the platform API requires them. Keep main-thread sections limited to UI/view work and move parsing, conversion, I/O, session negotiation, and CPU work to an owned executor/thread or async API.
Treat std::mutex as last-resort synchronization, not default callback or listener plumbing. Before adding one to a HybridObject, identify the concrete shared mutable values, the threads that can access them concurrently, and why one owner thread/queue, message passing, or immutable snapshots is not enough.
Never invoke JS/Nitro callbacks while holding a mutex. Copy or snapshot listener collections if needed, unlock, then call them. A listener removed during an in-flight emission may receive that current event.
Treat repeated executor, queue, platform, callback, and JS/Nitro runtime hops as an architecture smell. A HybridObject/session should own the executor/thread it works on, or cross into that owner once at the Promise, lifecycle, or native callback boundary. If an operation bounces through multiple nested contexts, redesign the object boundary or returned handle instead of adding more hops.
Do not fix lifecycle, readiness, or race bugs with sleep_for, usleep, timers, extra executor hops, or calling the same native method twice. Use an explicit Promise, callback, listener event, returned configured HybridObject, state transition, or owner queue instead. Retry only for external hardware, OS service, remote service, or network uncertainty, with bounded/cancellable/idempotent behavior.
Avoid manually creating or passing around std::shared_ptr<Promise<T>> by default. Prefer Promise<T>::async(...), Promise<T>::resolved(...), or Promise<T>::rejected(...) so the helper owns exactly-once completion. Use Promise<T>::create() only when bridging a native completion/callback API; keep it near the bridge, do not pass it through arbitrary helpers or session objects, and make every branch resolve or reject exactly once.
C++ style and organization
- Treat
cpp/HybridDataScanner.cppas the implementation file forHybridDataScanner, not as a dumping ground for unrelated geometry conversions, OpenCV helpers, platform adapters, or utility functions. - Keep one primary class, cohesive algorithm, or small variant family per file by default.
- Move reusable conversions, adapters, thread helpers, and platform shims into named files such as
GeometryConversions.cpp,FrameProcessorAdapter.cpp, orDataScannerSession.cpp. - Use anonymous namespaces only for small helpers that serve the file's primary type. Put larger reusable helpers in a
detailnamespace or an internal folder. - Use line count as a review signal: under roughly 300 lines is usually acceptable, while files above that need a concrete reason tied to one cohesive responsibility. A large file caused by helpers or glue belongs in multiple files.
- Put one-element conversions on the source type or a one-element converter function. The converter may return a vector/set when one source value expands to several native values.
- Compose collections where they are used with standard loops,
std::transform, set insertion, or accumulation. Keep an aggregate helper only when it owns real collection semantics such as deduplication, validation across elements, nonempty checks, batching, caching, or error aggregation.
Common Pitfalls
- Wrong namespace — The namespace must match
cxxNamespaceinnitro.json(e.g.margelo::nitro::math) - Forgetting `override` — All virtual method implementations need
override - Using `float` instead of `double` — Nitro uses
doublefor allnumbertypes - Inventing async return types — Generated async methods return
std::shared_ptr<Promise<T>>. Copy the generated signature exactly and preferPromise<T>::async(...),Promise<T>::resolved(...), orPromise<T>::rejected(...); usePromise<T>::create()only for real native completion/callback bridges. - Missing `TAG` member — Required for
HybridObject(TAG)constructor call - Letting one HybridObject file absorb every helper — Split converters, adapters, utility functions, and platform glue into named files. The filename should still describe the file after the implementation is done.
- Putting trivial transforms behind vector helpers — Prefer a one-element converter plus standard collection composition at the call site. A collection helper is justified only when the collection itself adds behavior such as deduplication or validation.
Related Skills
- cpp — General C++ API, ownership, and file-organization guidance
- native-nitrogen-codegen.md — Must generate specs before implementing
- spec-nitro-json.md — Configure
"c++"in autolinking - native-implement-kotlin.md — Android Kotlin alternative
- native-implement-swift.md — iOS Swift alternative
Skill: Implementing HybridObjects in Kotlin
Covers Steps 8–9 (Kotlin path): creating the Kotlin implementation class that extends the Nitrogen-generated Android spec.
Quick Pattern
Incorrect — missing required annotations:
class HybridMath : HybridMathSpec() {
override fun add(a: Double, b: Double) = a + b
}Correct — with required annotations:
@Keep
@DoNotStrip
class HybridMath : HybridMathSpec() {
override fun add(a: Double, b: Double): Double = a + b
}When to Use
- Implementing the Android side of a Nitro module in Kotlin
- When the spec uses
{ android: 'kotlin' } - After Nitrogen has generated
HybridMathSpec.kt
Prerequisites
- Nitrogen has generated
HybridMathSpec.kt, usually innitrogen/generated/android/kotlin/com/margelo/nitro/<namespace>/ nitro.jsonhas an Android autolinking entry with"language": "kotlin"and"implementationClassName": "HybridMath"
Step-by-Step
1. Locate the generated spec
nitrogen/generated/android/kotlin/com/margelo/nitro/math/HybridMathSpec.ktThis is the abstract class your implementation must extend. Do not edit it.
2. Create the implementation file
touch android/src/main/java/com/margelo/nitro/math/HybridMath.ktThe package must match androidNamespace from nitro.json.
3. Write the implementation class
package com.margelo.nitro.math
import androidx.annotation.Keep
import com.facebook.proguard.annotations.DoNotStrip
@Keep
@DoNotStrip
class HybridMath : HybridMathSpec() {
// Synchronous method
override fun add(a: Double, b: Double): Double = a + b
override fun subtract(a: Double, b: Double): Double = a - b
// Async method using Promise
override fun calculateFibonacci(n: Double): Promise<Double> {
return Promise.async {
var a = 0.0; var b = 1.0
for (i in 2..n.toInt()) {
val temp = a + b; a = b; b = temp
}
b
}
}
// Readonly property
override val pi: Double = Math.PI
// Read-write property
override var precision: Double = 6.0
}4. Add annotations — this is non-negotiable
@Keep— Prevents ProGuard/R8 from removing the class@DoNotStrip— Prevents Meta's code stripper from removing it
@DoNotStrip is required when ProGuard/R8 can strip the class. Keep @Keep as well to match generated Nitro code and common library implementations.
5. Verify using canonical Kotlin reference
For any type uncertainty, consult the canonical Kotlin test implementation: HybridTestObjectKotlin.kt
Code Examples
Type reference table
| TypeScript | Kotlin Type | Notes |
|---|---|---|
number | Double | Always Double |
string | String | |
boolean | Boolean | |
bigint (signed) | Long | |
bigint (unsigned) | ULong | Critical: ULong, not Long |
number[] | DoubleArray | Primitive array — NOT Array<Double> |
T[] (non-number) | Array<T> | e.g. Array<String>, Array<Person> |
Promise<T> | Promise<T> | Use Promise.async { } or Promise.parallel { } |
Promise<void> | Promise<Unit> | Kotlin Unit, not Void |
| `T \ | undefined` | T? |
(x: T) => void | (T) -> Unit | Lambda type, no @escaping needed |
() => T | () -> T | |
ArrayBuffer | ArrayBuffer | From nitro-modules core |
AnyMap | AnyMap | From nitro-modules core |
Record<string, T> | Map<String, T> | |
HybridObject | HybridSpec | Kotlin class, no shared_ptr |
null / NullType | NullType | NullType.NULL constant |
Date | java.time.Instant |
Async with Promise
Use the Promise helper that matches the work:
- Keep quick, deterministic, local work synchronous. Do not introduce
Promise, coroutines, or dispatchers for simple value construction, cached metadata, or pure transforms. Promise.asyncfor suspending or I/O work that should run through coroutines.Promise.parallelfor CPU-bound synchronous work that should run off the caller thread.- Avoid the main dispatcher unless the Android API requires it, such as view/UI mutation or lifecycle APIs. Keep main-thread blocks small and move parsing, conversion, I/O, session negotiation, and CPU work to an owned dispatcher or async API.
- Avoid manually creating or passing around
Promise<T>instances by default. PreferPromise.async,Promise.parallel,Promise.resolved, orPromise.rejectedso the helper owns exactly-once completion. A manual Promise is only justified when a native completion/listener/callback API cannot be wrapped as a suspend API; keep it near the bridge and make every branch resolve or reject exactly once. - Before writing a manual Promise for callback/listener APIs, look for or add a general suspend adapter in a focused extension file. For Google
Task<T>, createTask+await.ktonce and callreturn Promise.async(scope) { task.await() }from the HybridObject method. - Do not hand-wire
addOnSuccessListener/addOnFailureListener/addOnCanceledListenerinside public HybridObject methods when a reusableawait()adapter can express that API once. - Do not use
runBlockingin HybridObject methods, generated property getters/setters, or library callbacks. If callers must wait for a result, expose aPromise<T>method in the Nitro spec. - Treat repeated
launch,withContext, dispatcher, handler, executor, or JS/Nitro runtime hops as an architecture smell. A HybridObject/session should own the coroutine scope/dispatcher/lifecycle it works on, or cross into that owner once at the Promise, lifecycle, or native callback boundary. - If an operation bounces between main, IO, default, native, and JS/Nitro contexts in multiple nested places, redesign the HybridObject boundary or returned lifecycle handle instead of adding more hops.
- Do not fix lifecycle, readiness, or race bugs with
delay,Thread.sleep,Handler.postDelayed, timers, extra dispatcher hops, or calling the same native method twice. Use an explicit Promise, callback, listener event, Flow, returned configured HybridObject, or state transition instead. Retry only for external hardware, OS service, remote service, or network uncertainty, with bounded/cancellable/idempotent behavior. - Treat
Mutex,synchronized, andReentrantLockas last-resort synchronization. Before adding one to a HybridObject, identify the concrete shared mutable values, the threads/dispatchers that can access them concurrently, and why one owner dispatcher/lifecycle, message passing, or immutable snapshots is not enough. - Never invoke JS/Nitro callbacks while holding a lock. Snapshot listener collections if needed, unlock, then call them. A listener removed during an in-flight emission may receive that current event.
// Promise.async — for IO-bound or suspending work (uses coroutines)
override fun wait(seconds: Double): Promise<Unit> {
return Promise.async { delay(seconds.toLong() * 1000) }
}
// Promise.parallel — for CPU-bound synchronous work (runs on thread pool)
override fun calculateFibonacciAsync(value: Double): Promise<Long> {
return Promise.parallel { calculateFibonacciSync(value) }
}
// Promise.resolved — for instantly-resolved values
override fun promiseReturnsInstantly(): Promise<Double> {
return Promise.resolved(55.0)
}
// Promise.resolved() — for instantly-resolved void
override fun promiseThatResolvesVoidInstantly(): Promise<Unit> {
return Promise.resolved()
}For Google Tasks, put the generic bridge in Task+await.kt rather than wiring listeners in each HybridObject:
import com.google.android.gms.tasks.Task
import kotlinx.coroutines.suspendCancellableCoroutine
import kotlin.coroutines.resume
import kotlin.coroutines.resumeWithException
internal suspend fun <T> Task<T>.await(): T {
return suspendCancellableCoroutine { continuation ->
addOnSuccessListener { result ->
if (continuation.isActive) {
continuation.resume(result)
}
}
addOnFailureListener { error ->
if (continuation.isActive) {
continuation.resumeWithException(error)
}
}
addOnCanceledListener {
if (continuation.isActive) {
continuation.resumeWithException(RuntimeException("Task was canceled."))
}
}
}
}Accessing Android Context
Use NitroModules.applicationContext to get the ReactApplicationContext as it can be accessed anywhere in your hybrid objects. Always access it lazily via a property — never store it in a field, as it can be null during initialization.
import android.content.Context
import android.content.SharedPreferences
import com.margelo.nitro.NitroModules
@Keep
@DoNotStrip
class HybridStorage : HybridStorageSpec() {
// Lazily access context — throws if not yet set
private val context: ReactApplicationContext
get() = NitroModules.applicationContext ?: throw Error("No ApplicationContext set!")
// Use context to get system services or app storage
private val sharedPreferences: SharedPreferences
get() = context.getSharedPreferences("com.margelo.storage", Context.MODE_PRIVATE)
override fun getString(key: String): String? {
return sharedPreferences.getString(key, null)
}
override fun setString(key: String, value: String) {
sharedPreferences.edit().putString(key, value).apply()
}
}Rules:
- Always use
get()— neverval context = NitroModules.applicationContextat class level, it may be null at construction time - Always null-check with
?: throw Error(...)so failures are explicit, not silent NPEs ReactApplicationContextis a subclass of AndroidContext— use it forgetSharedPreferences,getSystemService, file access, etc.
Using callbacks
override fun compute(input: Double, onResult: (Double) -> Unit) {
val result = input * 2.0
onResult(result)
}Handling nullable / optional
Use Kotlin nullable types for real domain absence, not for modeling several possible object states. If fields are related, put them on a non-null variant type in the TypeScript spec and the generated Kotlin implementation.
override var optionalValue: Double? = null
override fun processOptional(value: Double?): Double {
return value ?: 0.0
}For closed Kotlin-only helper state, prefer sealed interface or sealed class over a data class with many nullable fields.
Properties and thread affinity
Generated Nitro properties are synchronous JS entry points. Keep val/var access cheap and local. Do not hide blocking work, runBlocking, Android service calls, permission flows, or thread hops in a getter or setter.
// Avoid: blocking async work hidden in a getter.
val status: SessionStatus
get() = runBlocking { session.status() }
// Prefer: make the async boundary explicit in the Nitro spec.
override fun getStatus(): Promise<SessionStatus> {
return Promise.async {
session.status()
}
}Throwing errors
override fun divide(a: Double, b: Double): Double {
if (b == 0.0) throw IllegalArgumentException("Division by zero!")
return a / b
}Validating configuration
Validate invalid values and required behavior early, but do not reject optional cross-platform preferences only because Android ignores them. If the operation still does its core job, ignore or degrade the preference and report support through the public capabilities or resolved state.
Examples:
- Throw when a requested flash mode cannot work because the device has no flash.
- Do not throw only because a quality, guidance UI, high-frame-rate, auto-zoom, or region preference is unsupported, unless the API documents that field as a hard requirement.
Kotlin style and organization
- Treat
HybridDataScanner.ktas the implementation file forHybridDataScanner, not as a dumping ground for Android helpers, geometry conversions, listeners, or extension utilities. - Prefer explicit
returnstatements inside multi-line control flow. Do not lift the return outside a multi-linetry/catch,if,when, or lambda just to make it expression-like; usetry { return value } catch (...) { return fallback }. - In multi-line lambdas, use labeled returns such as
return@map valuefor the result. Omit the label only for true single-expression lambdas likeitems.map { it.toString() }. - Prefer inline shorthand for unambiguous single-expression lambdas:
formats.map { it.toMLKitFormat() }, not a multi-linemap { format -> format.toMLKitFormat() }. Do not useitwhen a surrounding or nested lambda already usesit; name parameters in nested lambdas or when clarity needs it. - Keep exactly one top-level implementation type per file.
HybridDataScannerFactory.ktcontainsHybridDataScannerFactoryand no other classes, interfaces, enums, option adapters, coordinators, delegates, scanner sessions, or helper types. - Keep one focused extension/conversion per file. Do not create a catch-all Kotlin file for every extension in a feature.
- Never put Kotlin extension
fun,val, orvardeclarations insideHybrid*implementation files or other primary implementation files, even when they are private, tiny, or only used by that file. Put every extension in a separate namedType+operation.ktextension/converter file so code splitting, maintainability, and future diffs stay clean. - Move reusable extensions, Android adapters, conversion helpers, delegates/listeners, and protocol-style interfaces into named files such as
Extensions/ViewExtensions.kt,Conversions/PointConversions.kt,Barcode+toScannedCode.kt,TargetBarcodeFormat+toMLKitFormat.kt,BarcodeFormat+fromMLKitBarcodeFormat.kt, orDataScannerDelegate.kt. - Use
internalvisibility for helpers that should stay inside the module. - Keep
Hybrid*Factory.ktas orchestration only: resolve generated options, call validation/preflight helpers, build/start the native API, and return/reject the Promise. Do not define ML Kit/CameraX builder adapters, barcode mappings, companion converters, permission checks, manifest checks, capability checks, or scanner/session helper types in the factory file. - Extract Android preflight checks out of
Hybrid*factories and implementation methods. Permission checks, manifest feature/permission validation,PackageManagercapability checks, service availability checks, and similar setup guards belong in focused helper files. The factory should call those helpers in one or two readable lines. - Do not create broad
Utils.ktfiles for preflight checks. Use small files named after the platform type or domain check, and keep each file focused on one behavior. - Use line count as a review signal: under roughly 300 lines is usually acceptable only after the one-type-per-file and no-helpers-in-factory rules are satisfied. A large file caused by helpers or Android glue belongs in multiple files.
- Put one-element conversions on the source type. The element method may return a list/set when one source value expands to several native values.
- Do not put domain conversions on broad receivers such as
Int,String,Double, orAny, even as private helpers. Prefer the domain direction, such asBarcodeFormat.Companion.fromFormat(format: Int), overInt.toBarcodeFormat(). - Compose collections where they are used with
map,flatMap, folds, or sets. PreferTargetBarcodeFormat.toMLKitFormat()plusformats.map { it.toMLKitFormat() }at the call site overArray<TargetBarcodeFormat>.toMLKitFormats(). Keep an aggregate helper only when it owns real collection semantics such as deduplication, validation across elements, nonempty checks, batching, caching, or error aggregation, and place it in a focused conversion/configuration file rather than the main HybridObject file. - For converters that return or accept Android platform
Intconstants, apply the matching platform annotation when one exists, such as CameraX flash, capture, or mirror mode annotations. Check whether it is a Java@IntDefor Kotlin annotation and place it on the function, return value, or parameter according to its supported targets. - When converting from an annotated platform
Int, put the annotation on theformat: Intparameter if the annotation target supports parameters, for exampleBarcodeFormat.Companion.fromFormat(@SomeBarcodeFormat format: Int).
Common Pitfalls
- Missing `@Keep` or `@DoNotStrip` — The class will be removed in release builds, causing crashes
- Wrong package name — Must match
com.margelo.nitro.<androidNamespace>fromnitro.json - `Long` vs `ULong` — TypeScript
bigintwithuint64maps toULongnotLong - Not overriding all abstract members — Kotlin will fail to compile if any abstract member is missing
- `Promise<void>` vs `Promise<Unit>` — Kotlin uses
Unit, notvoid. AlwaysPromise<Unit>for void async methods - `Array<Double>` vs `DoubleArray` — Number arrays use
DoubleArray(primitive), other types useArray<T> - `Promise.async` vs `Promise.parallel` — Use
asyncfor IO/coroutine work,parallelfor CPU-bound sync work - Calling blocking code outside `Promise.async` — Network calls, delay, etc. must be inside
Promise.async { }(uses coroutines) - Hand-wiring callback APIs into manual Promises — Wrap general callback APIs once as suspend adapters, such as
Task<T>.await()inTask+await.kt, then call them fromPromise.async. - Lifting returns out of multi-line Kotlin control flow — Prefer explicit returns inside
try/catchbranches and labeled returns inside multi-line lambdas. Use implicit lambda results only for true one-line lambdas. - Expanding single-expression lambdas — Keep simple maps/flatMaps inline with
it, such asformats.map { it.toMLKitFormat() }, unless the lambda is nested or shorthand would be ambiguous. - Using `runBlocking` in generated entry points — Do not block JS-facing methods or properties; expose a
Promise<T>method or listener instead - Modeling variants with nullable clusters — Use distinct TypeScript/Nitro variants so Kotlin receives non-null related fields
- Storing `NitroModules.applicationContext` in a field — It can be null at construction time; always access it via a
get()property - Not null-checking `applicationContext` — Always use
?: throw Error("No ApplicationContext set!")to fail explicitly - Letting one HybridObject file absorb every helper — Split extensions, adapters, converters, and listeners into named files.
Hybrid*implementation files must not contain extensionfun/val/vardeclarations at all, even private ones. - Inlining platform preflight logic in factories — Do not bury permission checks, manifest validation, service availability checks, or hardware capability guards inside
Hybrid*Factorymethods. Put them in focused helper files and keep the caller short. - Turning factories into native implementation dumps — Do not place ML Kit/CameraX builder adapters, barcode mapping, companion converters, scanner option adapters, or session helper types below
Hybrid*Factory. The factory should orchestrate named helpers from separate files. - Putting multiple top-level types in one file — Native implementation files should contain one top-level type. Move helper classes, interfaces, enums, delegates, sessions, and option adapters into their own files.
- Extending primitive/common types for domain conversions — Do not add helpers like
Int.toBarcodeFormat(). Put the factory/converter on the domain type or companion, such asBarcodeFormat.Companion.fromFormat(format). - Putting trivial maps behind collection extensions — Prefer an element conversion plus
map/flatMapat the call site. A collection helper is justified only when the collection itself adds behavior such as deduplication or validation. - Returning unannotated Android int constants — When CameraX/Android exposes an annotation for a mode or format
Int, use it on converter functions or parameters so callers and tooling see the constrained value space.
Related Skills
- kotlin — General Kotlin API, nullability, coroutine, and threading guidance
- native-nitrogen-codegen.md — Must generate specs before implementing
- spec-nitro-json.md — Configure
"kotlin"in autolinking - native-implement-swift.md — iOS Swift counterpart
- native-implement-cpp.md — C++ cross-platform alternative
Skill: Implementing HybridObjects in Swift
Covers Steps 8–9 (Swift path): creating the Swift implementation class that implements the Nitrogen-generated iOS spec protocol/base typealias.
Quick Pattern
Incorrect — subclassing NSObject directly:
import Foundation
class HybridMath: NSObject {
func add(a: Double, b: Double) -> Double { a + b }
}Correct — implementing the generated spec:
import Foundation
import NitroModules
final class HybridMath: HybridMathSpec {
func add(a: Double, b: Double) throws -> Double { a + b }
}When to Use
- Implementing the iOS side of a Nitro module in Swift
- When the spec uses
{ ios: 'swift' } - After Nitrogen has generated
HybridMathSpec.swift
Prerequisites
- Nitrogen has generated
HybridMathSpec.swift, usually innitrogen/generated/ios/swift/ nitro.jsonhas an iOS autolinking entry with"language": "swift"and"implementationClassName": "HybridMath"react-native-nitro-modulesis a pod dependency
Step-by-Step
1. Locate the generated spec
nitrogen/generated/ios/swift/HybridMathSpec.swift ← generated protocol/base spec2. Create the implementation file
touch ios/HybridMath.swift3. Write the implementation class
import Foundation
import NitroModules
final class HybridMath: HybridMathSpec {
private static let queue = DispatchQueue(label: "com.margelo.math")
// Synchronous methods — most generated methods have `throws`
func add(a: Double, b: Double) throws -> Double {
return a + b
}
func subtract(a: Double, b: Double) throws -> Double {
return a - b
}
// Async method returning Promise — also has `throws`
func calculateFibonacci(n: Double) throws -> Promise<Double> {
return Promise.parallel(Self.queue) {
if n <= 1 { return n }
var previous = 0.0
var current = 1.0
for _ in 2...Int(n) {
let next = previous + current
previous = current
current = next
}
return current
}
}
// Readonly property
var pi: Double { Double.pi }
// Read-write property
var precision: Double = 6.0
}4. Add to the podspec
In the package podspec, ensure the implementation file and generated files are included. Current Nitro templates usually do this through add_nitrogen_files(s):
load 'nitrogen/generated/ios/NitroMath+autolinking.rb'
add_nitrogen_files(s)If the podspec manually lists source files, include ios/**/*.{h,m,mm,swift}, nitrogen/generated/ios/**/*.{h,hpp,cpp,mm,swift}, and any shared cpp/**/*.{hpp,cpp} files.
5. Verify using canonical Swift reference
For any type uncertainty, consult the canonical Swift test implementation: HybridTestObjectSwift.swift
Code Examples
Type reference table
| TypeScript | Swift Type | Notes |
|---|---|---|
number | Double | Always Double |
string | String | |
boolean | Bool | |
bigint (signed) | Int64 | |
bigint (unsigned) | UInt64 | |
T[] | [T] | e.g. [Double], [String], [Person] |
Promise<T> | Promise<T> | Promise.parallel(queue) { }, Promise.async { }, Promise.resolved(withResult:), or manual Promise() |
Promise<void> | Promise<Void> | Swift Void, not Unit |
| `T \ | undefined` | T? |
| `T \ | U` | Variant_T_U |
(x: T) -> void | @escaping (T) -> Void | Must be @escaping for stored callbacks |
() -> T | @escaping () -> T | |
ArrayBuffer | ArrayBuffer | From NitroModules |
AnyMap | AnyMap | From NitroModules |
Record<string, T> | [String: T] | Swift dictionary literal syntax |
HybridObject | any HybridSpec | Protocol existential, e.g. any HybridMathSpec |
null / NullType | NullType | .null value |
Date | Date | Foundation Date |
Async with Promise
Choose one concurrency model for the feature before implementing it.
- Keep quick, deterministic, local work synchronous. Do not introduce
Promise,Task, orDispatchQueuefor simple value construction, cached metadata, or pure transforms. - Use
Promise.parallel(queue)for DispatchQueue-owned work. This fits most AVFoundation and session-style APIs because you can keep all native state mutations on one queue. - Use
Promise.asynconly when the operation is naturally Swiftasync/awaitor Task-based from end to end. - Avoid
.mainunless the Apple API requires it, such as UIKit/AppKit/VisionKit presentation or view mutation. Keep main-thread blocks small and move parsing, conversion, I/O, session negotiation, and CPU work to an owned queue or async API. - Avoid
let promise = Promise<T>()by default. PreferPromise.parallel(queue),Promise.async,Promise.resolved, orPromise.rejectedso the helper owns exactly-once completion. A manual Promise is only justified when bridging a native completion/delegate/callback API; keep it near the bridge, do not pass it through arbitrary helpers or session objects, and make every branch resolve or reject exactly once. - Treat repeated
Task,DispatchQueue, actor, or JS/Nitro thread hops as an architecture smell. A HybridObject/session should own the queue/actor it works on, or cross into that owner once at the Promise, lifecycle, or native callback boundary. - If an operation bounces between main, background, JS, and native queues in multiple nested places, redesign the HybridObject boundary or returned lifecycle handle instead of adding more hops.
- Do not fix lifecycle, readiness, or race bugs with
DispatchQueue.asyncAfter,Task.sleep,Thread.sleep, timers, extra queue hops, or calling the same native method twice. Use an explicit Promise, delegate callback, listener event, returned configured HybridObject, or state transition instead. Retry only for external hardware, OS service, remote service, or network uncertainty, with bounded/cancellable/idempotent behavior. - Do not use
Task { @MainActor in ... }as a generic main-thread hop from a generated Nitro method. For UIKit/VisionKit callback and delegate APIs, use a manual Promise plus directDispatchQueue.main.asyncat the Nitro entry or callback boundary. - Direct
DispatchQueue.main.asyncclosures are recognized by Swift's actor checker for@MainActorcalls; generic queue wrappers such asPromise.parallel(.main)usually are not unless their closure is explicitly typed as@MainActor. - If an Apple completion can return on an arbitrary queue, normalize that completion to the chosen owner queue once, close to the callback source, instead of nesting repeated main-thread hops through the workflow.
- Do not mix Swift concurrency with
DispatchQueue.sync,DispatchQueue.main.sync,MainActor.assumeIsolated, orThread.isMainThreadworkarounds. If those seem necessary, use a queue-based design or change the public API boundary. - Never call
DispatchQueue.syncorDispatchQueue.main.syncin Nitro implementation code. This is especially dangerous in generated property getters and setters because those are synchronous JS entry points.
private static let cameraQueue = DispatchQueue(label: "com.margelo.camera.session")
func calculateFibonacciAsync(value: Double) throws -> Promise<Int64> {
return Promise.parallel(Self.cameraQueue) {
return try self.calculateFibonacciSync(value: value)
}
}Use Promise.async when you are intentionally wrapping Swift async/await or an API that naturally runs through Task.
func loadRemoteImage(url: URL) throws -> Promise<Data> {
return Promise.async {
let request = URLRequest(url: url)
let result = try await URLSession.shared.data(for: request)
return result.0
}
}
// Promise<Void> — void async (Swift uses Void not Unit)
func wait(seconds: Double) throws -> Promise<Void> {
return Promise.async {
let secondsUInt64 = UInt64(seconds)
let nanoseconds = secondsUInt64 * 1_000_000_000
try await Task.sleep(nanoseconds: nanoseconds)
}
}
// Promise.resolved — instant resolution with a value
func promiseReturnsInstantly() throws -> Promise<Double> {
return Promise.resolved(withResult: 55.0)
}
// Promise.resolved() — instant void resolution
func promiseThatResolvesVoidInstantly() throws -> Promise<Void> {
return Promise.resolved()
}
// Promise<T?> — resolves to undefined/nil
func promiseThatResolvesToUndefined() throws -> Promise<Double?> {
return Promise.resolved(withResult: nil)
}Threading and state
HybridObject methods and property access can be called synchronously from JS, including from multiple JS runtimes such as worklets. Do not model HybridObject implementations as Swift actors by default; actors make sync generated methods awkward and can hide where serialization happens.
Prefer one of these patterns:
- Keep cheap readonly state synchronous.
- Put mutable native/session state behind a private serial
DispatchQueue, and run mutating or fallible operations throughPromise.parallel(queue). - Make operations async when they must serialize, wait for hardware/session state, or cross queues.
- Emit listener events from the queue/thread that owns the state when callers need ongoing observations.
- Treat locks as a last-resort synchronization primitive, not a default safety wrapper. Before adding
NSLock, identify the concrete shared mutable values, the threads/queues that can access them concurrently, and why ownership byMainActor, a serial queue, a Nitro runtime/thread, or immutable snapshots is not enough. - Listener registries should usually be owned by the same queue/thread that emits their events. If Nitro add/remove calls can genuinely race with native delegate callbacks, use a tiny lock only around dictionary mutation and snapshot creation.
- Never invoke JS/Nitro callbacks while holding a lock. Snapshot listeners, unlock, then call them. A listener removed during an in-flight emission may receive that current event; cleanup only needs to prevent future emissions.
Using callbacks
func compute(input: Double, onResult: @escaping (Double) -> Void) {
let result = input * 2.0
onResult(result)
}Handling optional parameters
var optionalValue: Double? = nil
func round(value: Double, decimals: Double?) -> Double {
let places = decimals ?? 0
let multiplier = pow(10.0, places)
return Foundation.round(value * multiplier) / multiplier
}Throwing errors
Use guard for state/input validation. Throw RuntimeError for user-reachable failures. Do not expose NSError paths unless a generated API or Apple callback forces it, and convert those errors before they cross into JS.
func divide(a: Double, b: Double) throws -> Double {
guard b != 0 else {
throw RuntimeError("Division by zero!")
}
return a / b
}Validating configuration
Validate invalid values and required behavior early, but do not reject optional cross-platform preferences only because iOS ignores them. If the operation still does its core job, ignore or degrade the preference and report support through the public capabilities or resolved state.
Examples:
- Throw when a requested flash mode cannot work because the device has no flash.
- Do not throw only because a quality, guidance UI, high-frame-rate, auto-zoom, or region preference is unsupported, unless the API documents that field as a hard requirement.
Properties and thread affinity
private var _zoom: Double = 1.0
var zoom: Double {
get { _zoom }
set {
_zoom = newValue
}
}Writable properties are synchronous JS calls. Keep getters and setters cheap, local, and unlikely to fail. If applying a value requires queue hops, AVFoundation negotiation, permissions, allocation, or can fail, expose a method that returns Promise<Void> instead.
// Avoid: synchronous queue hop hidden in a getter.
var status: SessionStatus {
queue.sync { session.status }
}
// Prefer: make the queue boundary explicit.
func getStatus() throws -> Promise<SessionStatus> {
return Promise.parallel(Self.cameraQueue) {
return self.session.status
}
}
func setZoom(_ zoom: Double) throws -> Promise<Void> {
return Promise.parallel(Self.cameraQueue) {
try self.applyZoomToCamera(zoom)
}
}If state can only be observed on a specific queue, prefer a listener or event API emitted from that queue instead of a getter.
Swift style and organization
- Make HybridObject implementation classes
finalunless inheritance is genuinely required. - Use Swift types such as
String,[String: T], arrays, structs, and typed Foundation values. Avoid Objective-C bridge types such asNSString,NSDictionary,NSArray, andNSObjectinheritance unless an Apple API requires them. - Treat
ios/HybridDataScanner.swiftas the implementation file forHybridDataScanner, not as a dumping ground for Swift extensions, UI helpers, geometry conversions, delegates, or native protocols. - Keep exactly one top-level implementation type per file.
HybridDataScannerFactory.swiftcontainsHybridDataScannerFactoryand no other structs, classes, enums, protocols, option adapters, coordinators, delegates, scanner sessions, or helper types. - Put reusable conversions, Apple framework helpers, and small protocol conveniences in focused Swift extension files under
ios/Extensions/, such asios/Extensions/UIViewController+topPresentedViewController.swift,ios/Extensions/CGPoint+Point.swift,ios/Extensions/Barcode+toScannedCode.swift, orios/Extensions/AVFoundation/AVCaptureDevice+withLock.swift. - Never put Swift extensions inside
Hybrid*implementation files or other primary implementation files, even when the extension is private, tiny, or only used by that file. Put every extension in a separate namedType+operation.swiftextension/converter file so code splitting, maintainability, and future diffs stay clean. - Move delegates, framework adapters, converters, native protocols, and helper state into separate named files. Use
internalorpackagevisibility when the helper should not be public API. - Keep
Hybrid*Factory.swiftas orchestration only: resolve generated options, call validation/preflight helpers, create/start the native session, and return/reject the Promise. Do not defineNative*Optionsstructs, session/coordinator/delegate classes, presenter traversal helpers, Vision/VisionKit barcode mappings, scanner configuration builders, permission switches, Info.plist checks, or availability checks in the factory file. - Extract platform preflight checks out of
Hybrid*factories and implementation methods.AVCaptureDevice.authorizationStatus(...)switches,Bundle.main.object(forInfoDictionaryKey: "NSCameraUsageDescription")guards, hardware support checks, and similar setup validation belong in focused helper/extension files such asAVCaptureDevice+CameraAuthorization.swiftorBundle+CameraUsageDescription.swift. The factory should call those helpers in one or two readable lines. - Do not create broad
Utils.swiftfiles for preflight checks. Use small files named after the platform type or domain check, and keep each file focused on one behavior. - Use line count as a review signal: under roughly 300 lines is usually acceptable only after the one-type-per-file and no-helpers-in-factory rules are satisfied. A large file caused by extension methods, helper variables, or platform glue belongs in multiple files.
- Prefer inline shorthand for unambiguous single-expression closures:
targetFormats.flatMap { $0.toVNBarcodeFormat() }, not a multi-lineflatMap { format in format.toVNBarcodeFormat() }. Do not use$0when a surrounding or nested closure already uses shorthand arguments; name parameters in nested closures or when clarity needs it. - Put one-element conversions on the source type. A Vision conversion should live in a file such as
ios/Extensions/RecognizedDataType+DataScannerRecognizedDataType.swift; it can return[DataScannerViewController.RecognizedDataType]orSet<DataScannerViewController.RecognizedDataType>when one source value expands to several native values. - Do not put domain conversions on broad receivers such as
Int,String,Double,Any,CGPoint, orCGRectunless the conversion is genuinely about that type. Prefer the domain type direction, such asBarcodeFormat.from(format:)orBarcodeFormat(nativeFormat:), overInt.toBarcodeFormat(). - Compose collections where they are used, for example
Set(try dataTypes.flatMap { try $0.toVisionRecognizedDataTypes() }). Keep an aggregate helper only when it owns real collection semantics such as deduplication, validation across elements, nonempty checks, batching, caching, or error aggregation, and place it in a focused conversion/configuration file rather thanHybridDataScanner.swift. - Break complex expressions into named intermediate values. Avoid inline chains that allocate, convert units, and call another API in one expression.
- Pass named constants or variables into API calls instead of building values inline when the expression has meaningful steps.
let radians = angleDegrees * .pi / 180.0
let rotatedPoint = point.rotated(by: radians)
let projectedPoint = projection.project(rotatedPoint)
renderer.render(point: projectedPoint)Common Pitfalls
- Forgetting `import NitroModules` — The spec protocol won't be found without this import
- Subclassing `NSObject` instead of the spec —
class HybridMath: NSObjectwon't satisfy the generated protocol - Method signature mismatch — Every parameter name and type must exactly match the generated spec
- Forgetting `throws` keyword — Most generated methods have
throws; check the generated spec to confirm which ones do - `Promise<void>` vs `Promise<Void>` — Swift uses
Void(not Kotlin'sUnit). AlwaysPromise<Void> - Using `Promise.async` for DispatchQueue APIs — Prefer
Promise.parallel(queue)for AVFoundation/session work. UsePromise.asyncfor Swiftasync/awaitor Task-based APIs. - Using `DispatchQueue.sync` or `DispatchQueue.main.sync` — Treat synchronous queue hops as a design bug. Make the Nitro API async or event-based instead.
- Mixing actors and queues with escape hatches — Do not use
MainActor.assumeIsolated,Thread.isMainThread, or queue sync calls to force an async/await design onto queue-owned APIs. - Callbacks without `@escaping` — Stored/async callbacks must be
@escaping; the generated spec will tell you - `Dictionary<String,T>` vs `[String:T]` — Both work;
[String:T]is the idiomatic Swift syntax - `any HybridSpec` not `HybridSpec` — In modern Swift, protocol types need the
anykeyword - Not including the file in podspec — Swift files must be in the
source_filesglob in.podspec - Using the `override` keyword — Swift implementations conform to the generated spec shape; methods and properties declared by the spec must NOT use
override(unlike the Kotlin counterpart, which does).overrideonly applies when overriding a superclass member. - Defaulting HybridObjects to `actor` — JS-facing methods and properties are synchronous entry points. Prefer queue-owned state and async methods where serialization is needed.
- Leaking Objective-C types — Avoid
NSDictionary,NSString,NSArray, andNSErrorin Nitro implementation APIs unless required by an Apple API boundary. - Letting one HybridObject file absorb every helper — Split extensions, delegates, converters, and protocols into named files.
Hybrid*implementation files must not contain extensions at all, even private ones. - Inlining platform preflight logic in factories — Do not bury authorization switches, Info.plist checks, or hardware capability guards inside
Hybrid*Factorymethods. Put them in focused helper/extension files and keep the caller short. - Turning factories into native implementation dumps — Do not place
Native*Options, session/coordinator/delegate classes, presenter lookup, Vision/VisionKit mappings, scanner builders, or availability checks belowHybrid*Factory. The factory should orchestrate named helpers from separate files. - Putting multiple top-level types in one file — Native implementation files should contain one top-level type. Move helper structs, classes, enums, protocols, delegates, sessions, and option adapters into their own files.
- Expanding single-expression closures — Keep simple maps/flatMaps inline with
$0, such astargetFormats.flatMap { $0.toVNBarcodeFormat() }, unless the closure is nested or shorthand would be ambiguous. - Extending primitive/common types for domain conversions — Do not add helpers like
Int.toBarcodeFormat(). Put the factory/converter on the domain type with a static method or initializer. - Putting trivial maps behind collection extensions — Prefer an element conversion plus
map/flatMapat the call site. A collection helper is justified only when the collection itself adds behavior such as deduplication or validation.
Related Skills
- swift — General Swift API, concurrency, and threading guidance
- native-nitrogen-codegen.md — Must generate specs before implementing
- spec-nitro-json.md — Configure
"swift"in autolinking - native-implement-kotlin.md — Android Kotlin counterpart
- native-implement-cpp.md — C++ cross-platform alternative
Skill: Running Nitrogen and Verifying Generated Code
Covers Steps 6–7: running the Nitrogen codegen tool and verifying the generated native files.
Generated files under nitrogen/generated/ are build outputs. Do not manually edit them; update .nitro.ts specs or native implementation files and re-run nitrogen. Generated files may be committed to git, and many Nitro libraries do this, but the project can also choose not to. They must be present in the npm package.
Quick Commands
# From the package folder
cd packages/react-native-math
bunx nitrogen
# OR add a root script and run from root:
# In root package.json: "specs": "bun --cwd packages/react-native-math run specs"
bun specsWhen to Use
- After writing the
*.nitro.tsspec AND updatingnitro.json - After any change to the spec file (re-run to regenerate)
- When generated files are missing or out of date
Prerequisites
*.nitro.tsspec file written and savednitro.jsonconfigured with correct autolinkingreact-native-nitro-modulesinstalled in the package
Step-by-Step
1. Navigate to the package folder
cd packages/react-native-mathNitrogen must be run from the package root (where nitro.json lives), not from the monorepo root.
2. Run Nitrogen
bunx nitrogenOr if the package has a specs script in its package.json:
bun run specs
# or from monorepo root with:
bun specs3. Add a specs script to the package package.json
{
"scripts": {
"typecheck": "tsc --noEmit",
"specs": "tsc --noEmit false && nitrogen"
}
}4. Add a root shortcut
In the monorepo root package.json:
{
"scripts": {
"specs": "bun --cwd packages/react-native-math run specs"
}
}5. Verify the generated folder structure
After running, check nitrogen/generated/:
nitrogen/generated/
├── ios/
│ ├── NitroMath+autolinking.rb
│ ├── NitroMathAutolinking.swift
│ ├── c++/
│ └── swift/
│ └── HybridMathSpec.swift ← Swift protocol/base spec
├── android/
│ ├── NitroMath+autolinking.gradle
│ ├── NitroMath+autolinking.cmake
│ ├── c++/
│ └── kotlin/com/margelo/nitro/math/
│ └── HybridMathSpec.kt ← Kotlin abstract class
└── shared/
└── c++/
├── HybridMathSpec.hpp ← C++ abstract class
└── HybridMathSpec.cpp ← Generated C++ glueOlder generated layouts may be flatter, but current Nitro output commonly uses ios/swift, ios/c++, android/kotlin, android/c++, and shared/c++ subdirectories.
6. Ensure generated sources are wired into the native build
If the Nitro template already added these hooks, verify them instead of duplicating them.
In the iOS podspec, load the generated autolinking Ruby file and call add_nitrogen_files(s):
load 'nitrogen/generated/ios/NitroMath+autolinking.rb'
add_nitrogen_files(s)In Android build.gradle, load the generated Gradle file:
apply from: '../nitrogen/generated/android/NitroMath+autolinking.gradle'In Android CMakeLists.txt, include the generated CMake file after add_library(...):
include(${CMAKE_SOURCE_DIR}/../nitrogen/generated/android/NitroMath+autolinking.cmake)In the JNI entry point, call the generated native registration function:
#include "NitroMathOnLoad.hpp"
JNIEXPORT jint JNICALL JNI_OnLoad(JavaVM* vm, void*) {
return facebook::jni::initialize(vm, []() {
margelo::nitro::math::registerAllNatives();
});
}7. Troubleshoot missing files
If no files are generated:
- Verify the spec file ends in
.nitro.ts(not.ts) - Verify the interface name in the spec matches the autolinking key in
nitro.json - Check that you ran
bunx nitrogenfrom inside the package folder (wherenitro.jsonis) - Run with verbose output:
bunx nitrogen --verbose
Older Generated Layout Example
Some older generated trees look like:
nitrogen/generated/
├── ios/
│ └── HybridMathSpec.swift ← Swift protocol/base spec
├── android/
│ └── com/margelo/nitro/math/
│ └── HybridMathSpec.kt ← Kotlin abstract class
└── shared/
├── HybridMathSpec.hpp ← C++ abstract class
└── NitroMathSpecs.hpp ← Registry headerCode Examples
Example generated Swift spec shape
// nitrogen/generated/ios/swift/HybridMathSpec.swift
public protocol HybridMathSpec_protocol: HybridObject {
func add(a: Double, b: Double) throws -> Double
}
open class HybridMathSpec_base {
// Generated C++ bridge ownership lives here.
}
public typealias HybridMathSpec = HybridMathSpec_protocol & HybridMathSpec_baseOlder generated Swift specs may use a single protocol instead of the protocol/base typealias, but implementation code should still inherit or conform to HybridMathSpec.
Generated HybridObject requirements
Generated specs also include the base HybridObject requirements:
var hybridContext: margelo.nitro.HybridContext { get set }
var memorySize: Int { get }Example generated Kotlin spec
// nitrogen/generated/android/kotlin/com/margelo/nitro/math/HybridMathSpec.kt
abstract class HybridMathSpec: HybridObject() {
abstract fun add(a: Double, b: Double): Double
}Root package.json scripts
{
"scripts": {
"specs": "bun --cwd packages/react-native-math run specs",
"example": "bun --cwd apps/example"
}
}Common Pitfalls
- Running from wrong directory — Always run
bunx nitrogenfrom the package root (wherenitro.jsonis), not the monorepo root - Re-run after every spec change — Generated files go stale the moment you modify the
.nitro.tsfile - Stale generated files — If you rename an interface, delete old generated files first to avoid conflicts
Related Skills
- spec-hybrid-object.md — Must write the spec before running nitrogen
- spec-nitro-json.md — Must configure
nitro.jsonbefore running nitrogen - native-implement-swift.md — Next: implement the generated Swift spec
- native-implement-kotlin.md — Next: implement the generated Kotlin spec
- native-implement-cpp.md — Next: implement the generated C++ spec
Skill: One-Command Releases with release-it
Use this when setting up or reviewing package releases. The repo should expose one command:
bun releaseUse release-it. Do not require maintainers to remember separate npm publish, tag, changelog, lockfile, and GitHub release commands.
Single Package
For a single-package repo where the root package is the publishable package, the package can run release-it directly and own npm publishing:
{
"scripts": {
"release": "release-it"
},
"devDependencies": {
"@release-it/conventional-changelog": "^11.0.0",
"release-it": "^20.0.0"
},
"release-it": {
"npm": {
"publish": true
},
"github": {
"release": true
},
"plugins": {
"@release-it/conventional-changelog": {
"preset": "conventionalcommits"
}
}
}
}Multiple Packages
For workspaces, release from the root with bun release. Use this pattern for multiple publishable packages, and also for one publishable package inside packages/ when the root needs to version examples, changelogs, tags, GitHub releases, or lockfiles:
- Each package has
"release": "release-it"and package-levelrelease-itconfig with"npm.publish": true,"git": false, and"github.release": false. - The root
"release": "./scripts/release.sh"script runs each package release sequentially, then runs rootrelease-it. - Root
release-itowns the version bump commit, tag, changelog, and GitHub release, with"npm.publish": false. - Root
release-it.git.requireCleanWorkingDirmust befalse; package releases and lockfile updates create diffs before the root release commit. - After the root version bump and before the release commit, refresh and stage lockfiles. Run
bun installforbun.lock, and run the example app's bundle/pod install soPodfile.lockis current. Use therelease-ithook that runs in that window for the repo, such asafter:bumporbefore:git.
Root script pattern:
#!/bin/bash
set -e
for pkg in packages/*; do
[ -d "$pkg" ] || continue
(cd "$pkg" && bun release "$@")
done
bun run release-it "$@"Root release config shape:
{
"scripts": {
"release": "./scripts/release.sh"
},
"devDependencies": {
"@release-it/bumper": "^7.0.0",
"@release-it/conventional-changelog": "^11.0.0",
"release-it": "^20.0.0"
},
"release-it": {
"npm": {
"publish": false
},
"git": {
"commitMessage": "chore: release ${version}",
"tagName": "v${version}",
"requireCleanWorkingDir": false
},
"github": {
"release": true
},
"hooks": {
"before:release": "bun install && bun run build && bun specs",
"after:bump": "bun install && git add bun.lock && bun example bundle-install && bun example pods && git add apps/example/ios/Podfile.lock"
},
"plugins": {
"@release-it/bumper": {
"out": [
{
"file": "packages/react-native-math/package.json",
"path": "version"
},
{
"file": "apps/example/package.json",
"path": "version"
}
]
},
"@release-it/conventional-changelog": {
"preset": "conventionalcommits"
}
}
}
}Adapt the lockfile hook to the actual example layout, such as example/ios/Podfile.lock for top-level example/.
Common Pitfalls
- No `bun release` alias — Releases must use one command.
- Root clean-working-tree checks in monorepos — Set root
release-it.git.requireCleanWorkingDirtofalsewhen package releases run before the root release commit. - Stale lockfiles after version bumps — Refresh and stage
bun.lockand the example app'sPodfile.lockafter the root bump and before rootrelease-itcreates the release commit. - Mixed responsibilities — Package release configs publish npm packages. Root release config creates the git commit, tag, changelog, and GitHub release.
Skill: Repository Structure and Workflow
Use this when creating a new repo, reorganizing a repo, adding examples/docs/CI, or making a large feature that changes package layout.
Recommended Layout
<root>/
├── README.md
├── package.json
├── bun.lock
├── packages/
│ └── react-native-math/
├── apps/
│ └── example/
├── docs/
├── scripts/
├── config/
└── .github/
└── workflows/Rules
- Name the default branch
main. Do not initialize new repos, workflow refs, docs snippets, release config, or branch protection aroundmaster. - Keep the root small so
README.mdstays visible in GitHub's file list. Root files should mostly beREADME.md,package.json, lockfiles,packages/, one example-app location, optionaldocs/, optionalscripts/,config/,.github/, and root-only config files required by tools. - Add
README.mdin the first repo setup pass. Prefer a Margelo-style graphic banner at the top when a real banner asset exists or can be created cleanly; otherwise start with# <LibraryName>. Use VisionCamera, Nitro, and MMKV as README reference patterns. The README should quickly show the catchy one-line value proposition, install command, minimal usage, docs/API links, example app link, platform support, and support/community links. - Put publishable libraries in
packages/<package-name>/. - Use
apps/<example-name>/when the repo may need multiple example apps, including optional native dependencies, feature variants, or integration demos. - Use top-level
example/only for intentionally small single-example repos that should preserve React Native's generated paths. - Keep example apps close to the official React Native template. Use official APIs, generated configs, and template-supported extension points before custom setup.
- Add
docs/only whenREADME.mdis not enough. Prefer Fumadocs over Docusaurus for a full docs site, deploy it on Vercel, and use a short Margelo subdomain when appropriate, such as<simple-name>.margelo.com. - Generate API docs from JSDoc with TypeDoc or a similar tool. JSDoc should link related APIs with
{@linkcode ...},@see, and real docs URLs so users can click through the API reference like Apple-style docs. - Add
scripts/only for reusable repo automation. - Put shared config under
config/when tools can reference it, including TypeScript configs, lint/format configs,.swift-format,.clang-format, and.editorconfig. If a tool requires a root config file, keep the root file minimal and delegate toconfig/. - Prefer Bun commands:
bun install,bun run,bun --cwd, andbunx. Usenpxonly when Bun cannot run the tool. - Add
.github/workflows/for CI validation on PRs and pushes tomain. CI must run TypeScript compilation/build checks and code style checks, including formatting/linting for JS/TS and native code that exists in the repo. - Prefer
react-native-harnessfor GitHub Actions end-to-end tests in a real React Native environment. Do not add standalone native tests such as Kotlin JUnit or XCTest unless the library also exposes a native target outside React Native or the behavior cannot be validated through the React Native API. - Keep GitHub repository metadata and npm metadata polished for search: catchy description, website, topics/tags, package
description,keywords,author/contributors,repository,homepage,bugs, and funding/support links when relevant. - Do not add Husky, commitlint, lint-staged, pre-commit hooks, pre-push hooks, or
preparescripts that install hooks. Validation belongs in CI. - Avoid
patch-package, postinstall rewrites, monkeypatching, and workaround layers. Fix package layout, autolinking, generated config, official extension points, or upstream issues first. If a patch is unavoidable, keep it narrow, link the upstream issue or PR, and remove it once the root cause is fixed. - Work on a separate branch and open a draft PR early so CI can run while local work continues.
- Use squash merges for
main. - After initial release or after major rewrites settle, keep PRs atomic unless changes are tightly coupled.
Root Workspace
For apps/example:
{
"name": "react-native-math-root",
"private": true,
"workspaces": [
"packages/*",
"apps/*"
],
"scripts": {
"specs": "bun --cwd packages/react-native-math run specs",
"example": "bun --cwd apps/example"
}
}For top-level example/, use:
{
"workspaces": [
"packages/*",
"example"
],
"scripts": {
"example": "bun --cwd example"
}
}CI Baseline
Start with:
- TypeScript/build:
bun install --frozen-lockfile,bun run typecheck,bun run build,bun run specs - JS lint/format: Biome, or ESLint/Prettier if already used
- Native lint/format for native code in the repo: SwiftLint or swift-format, clang-format or clang-tidy/cpplint, ktlint or Detekt
- End-to-end behavior:
react-native-harnesson GitHub Actions when the feature needs real React Native runtime coverage
Common Pitfalls
- Default branch drift — Do not leave new repos or docs pointing to
master; usemain. - Weak first impression — A README without a banner or clear library-name heading, value proposition, install, usage, and links makes the package harder to evaluate and find.
- Root pollution — Move config into
config/when tools support it. - Two example locations — Use either
apps/exampleorexample/, not both. - Docs-site sprawl — Prefer Fumadocs on Vercel for Margelo docs sites instead of adding a heavier docs framework by default.
- Hook frameworks — Do not add commit/push hooks; use CI.
- Native-only tests for RN APIs — Prefer harness E2E coverage unless there is a standalone native target or RN cannot exercise the behavior.
- Patch layers — Avoid patches and postinstall rewrites; when temporary patches are unavoidable, track the upstream fix and keep the patch isolated.
- Late PRs — Open a draft PR early so CI runs while implementation continues.
Related skills
How it compares
Pick build-nitro-modules for Margelo Nitro native modules in React Native; pick Expo module templates when a simpler JavaScript-only config plugin suffices.
FAQ
What is build-nitro-modules for?
build-nitro-modules helps developers scaffold React Native Nitro Modules with TypeScript bindings and C++ native code, using Margelo's Nitro framework for faster bridges than legacy NativeModules.
When should developers use Nitro Modules?
build-nitro-modules fits React Native apps where camera, crypto, audio, or other hot paths need native C++ performance that JavaScript-only implementations cannot sustain at 60fps.