Skip to content
Back to skills

Native Modules And New Architecture

ASecurity

Writes New Architecture native code in typed local Expo modules. Use when adding Swift or Kotlin, a local or Turbo module, codegen, or checking a library for New Architecture. Not for config plugins (expo-prebuild-and-config-plugins).

  • 3 stars
  • 0 votes
  • 0 copies
  • 1 view
  • Added October 18, 2026
developmentjavascriptgojavaswiftkotlinc++reactnodetestingapi

Works with

  • cli
  • api

Security analysis

A100/100

Pro scans all 20 files and shows the line behind each finding

Scanned October 18, 2026

npx -y skills add zakariaf/ReactNative-Skills --skill native-modules-and-new-architecture --agent claude-code

Installs into .claude/skills of the current project.

Are you the author of Native Modules And New Architecture?

Add the live security badge to your README. It updates with every re-scan.

Security grade badge for Native Modules And New Architecture
[![Security: A — Skills Directory](https://www.skillsdirectory.com/api/skills/zakariaf-native-modules-and-new-architecture/badge)](https://www.skillsdirectory.com/skills/zakariaf-native-modules-and-new-architecture)

More formats (shields.io, HTML) on the badges page. Keep it an A: scan every change in CI with Pro.

Download with Pro
SKILL.md
---
name: native-modules-and-new-architecture
description: Writes New Architecture native code in typed local Expo modules. Use when adding Swift or Kotlin, a local or Turbo module, codegen, or checking a library for New Architecture. Not for config plugins (expo-prebuild-and-config-plugins).
---

# Native modules and the New Architecture

Makes the app's own native code safe to own on iOS and Android: native code only where no package
fits, written with the Expo Modules API in a local module under `modules/<name>/`, reached through one
typed, lazy, optional binding in its adapter behind a port, with the binding type and the Swift and
Kotlin definitions proven equal; Turbo Modules only where shared C++ or publishing needs them; and
every third-party native library either native on the New Architecture or a recorded decision. Two
zero-dependency checkers prove it.

## Rules that must hold

1. **Native code comes last, and lives in a local Expo module.** First an Expo SDK package, then a
   library that is native on the New Architecture, then a config plugin; only then a local module made
   by `npx create-expo-module@latest --local`, in Swift and Kotlin with the Expo Modules API. No native
   source outside `modules/<name>/` (no inline modules, no edits to generated `ios/`/`android/`) and no
   legacy bridge code (`RCT_EXPORT_MODULE`, `ReactContextBaseJavaModule`, `@ReactMethod`). Why: SDK 55 <!-- stack-pin-ignore: the release that changed it -->
   and later run only on the New Architecture, legacy modules run only through interop layers React
   Native will remove, and native code outside the module is never built or reviewed.
   (`native-placement`, `legacy-native-api`)
2. **The module folder is complete.** `expo-module.config.json` declares each platform and lists every
   `Module` class (Swift class names, Kotlin full names); every listed class exists, extends `Module`
   and is `public` in Swift; `ios/*.podspec` depends on `ExpoModulesCore`; `android/build.gradle`
   applies `expo-module-gradle-plugin`. Why: autolinking registers only listed classes, and a missing
   piece fails one platform's build or leaves the module unreachable.
   (`module-config`, `module-class`, `module-build-files`)
3. **One JavaScript name per module.** `Name("TimeChange")` is written explicitly, is identical in
   Swift and Kotlin, and is unique in the app. Why: the name is the lookup key; inferred or differing
   names find the module on one platform only. (`module-name`)
4. **One binding per module, in its adapter, behind a port.** `requireOptionalNativeModule` and
   `requireNativeView` appear only in the module's adapter, `src/services/<concern>/native-<concern>-adapter.ts`
   (the checker accepts any `<library>-<concern>-adapter.ts(x)` in `src/services`, following
   architecture-and-boundaries), with a literal name a local module or spec defines; every module is
   bound, by exactly one adapter (platform variants `-adapter.ios.ts`/`.android.ts` count as one);
   `TurboModuleRegistry.get` appears only in a codegen spec (`Native<Name>.ts`); no
   `NativeModules`, `requireNativeComponent`, `NativeEventEmitter` or `UIManager`. The port, fake and
   composition root follow architecture-and-boundaries. Why: one line reaches native code, so one
   place guards it and everything above it runs in Jest on the fake; a second binding is a second,
   unguarded copy of the contract. (`binding-place`, `binding-unique`, `binding-name`, `codegen-spec`,
   `legacy-js-api`)
5. **The binding is lazy and optional.** The lookup runs in the factory's default parameter, never at
   module scope, and uses `requireOptionalNativeModule` for both kinds of module (for a Turbo Module it
   falls back to `TurboModuleRegistry.get`): a missing module becomes a typed `unavailable` failure.
   Why: the module is missing in Jest, on web, in a dev client or store build older than the module,
   and when an update reaches an older binary; the throwing forms crash at import there.
   (`binding-lazy`, `binding-optional`)
6. **The binding type is the cross-language contract.** `requireOptionalNativeModule<TimeChangeBinding>`
   names a type declared in the adapter that lists every native function, property and event by its
   native name, and nothing else; Swift, Kotlin and the type change in one commit. Why: the Expo Modules
   API has no cross-platform codegen (`expo-type-information` drafts a type from Swift only), so a
   renamed member compiles everywhere and fails at runtime. For a Turbo Module the codegen spec is the
   contract: bind `requireOptionalNativeModule<Spec>(...)` with `import type { Spec }` from its
   `Native<Name>.ts`; a hand-written type is compared with the spec. (`binding-type`, `contract-drift`)
7. **Same surface on iOS and Android, or a declared difference.** A member one platform lacks is
   optional in the binding type (`isAutomaticTimeEnabledAsync?()`) and the adapter maps its absence to
   `unsupported`; a one-platform module says so in `platforms`. Why: an undeclared gap is "is not a
   function" on one platform only, found by users. (`platform-parity`)
8. **Definitions stay thin, respect threads and release what they observe.** Each closure calls a plain
   helper that imports no Expo type; `Function`/`Property`/`Constant` only do cheap reads; UI work runs
   in `AsyncFunction(...).runOnQueue(.main)` (`Queues.MAIN`); OS observation starts in
   `OnStartObserving`, stops in `OnStopObserving` (same event, or the unnamed form) and `OnDestroy` in
   the Module itself, and every observer or receiver added is removed in the same file. Why: sync
   functions block the JavaScript thread, async ones default to a background queue, and a leaked
   observer keeps firing and keeps the module alive; a helper with a correct `stop()` still leaks when
   the Module never calls it. (`main-thread`, `observer-release`, `observer-stop`)
9. **Turbo Modules only for shared C++ or publishing, built as a local package.** `modules/<name>/` with
   `package.json` `codegenConfig` (`name`, `type`, a `jsSrcsDir` holding specs,
   `android.javaPackageName`), its podspec at the package root, specs named `Native<Name>.ts` declaring
   `export interface Spec extends TurboModule`; never `codegenConfig` in the app's `package.json`. Why:
   codegen reads only those files, autolinking looks for a React Native module's podspec only at its
   root, and CNG deletes app-level native code. (`codegen-config`, `codegen-spec`)
10. **Every native dependency runs on the New Architecture natively, or is a recorded decision.** A
    dependency without `expo-module.config.json`, `codegenConfig` or a Nitro marker has an entry in
    `contracts/native-libraries.json` (architecture, keep or replace, reason, evidence, `recheckBy`);
    entries stay true and are rechecked by their date. Why: such a library runs through the interop
    layer, whose limits and removal the app must plan for, not discover.
    (`library-legacy`, `library-entry`, `library-stale`, `library-recheck`)
11. **expo-doctor's React Native Directory check stays on.** No `enabled: false`; every exact-name
    `exclude` has a decision entry; no regex exclusions. Why: the directory check is the only automatic
    warning for unmaintained or New-Architecture-incompatible packages. (`doctor-check`)
12. **Proven on both platforms before done; a native change is a new binary.** Jest covers the fake and
    the adapter's mapping; `npx expo-modules-autolinking resolve` lists the module for apple and
    android; the Swift type-checks and the Kotlin helpers compile (testing-and-proof.md); a dev client
    on a named simulator and emulator exercises every member. Why: Fast Refresh and over-the-air
    updates never carry native code, and each layer catches what the others cannot.
13. **Stop and ask before** a new native module, a Turbo Module instead of an Expo module, keeping an
    interop library, an expo-doctor exclusion, or a `nativeSourceDirs` entry. Why: each binds the app
    for every later SDK and OS move.

## Workflow

1. **Decide whether native code is needed.** Read [references/decisions.md](references/decisions.md)
   sections 1 to 4; walk the order of reach. A native package: go to step 7. Ask the developer before
   writing a module (rule 13).
2. **Create the module.** From the app root:
   `EXPO_NONINTERACTIVE=1 npx create-expo-module@latest --local <name> --name <Pascal> --platform apple android --features Function AsyncFunction Event`.
   Delete its `src/` folder (the binding lives in the adapter). Shape it like
   `templates/modules/time-change/` (config, podspec, Gradle file, a thin `<Pascal>Module` per platform
   over a plain `<Pascal>System` helper). API details: [references/expo-modules-api.md](references/expo-modules-api.md).
3. **Write the port, fake and adapter.** Copy `templates/src/services/time-change/` as the pattern:
   `<concern>-port.ts` (Result failures, unsubscribe), `fake-<concern>.ts` + test first, then
   `native-<concern>-adapter.ts` with the binding type, the lazy optional binding and the error mapping,
   and its test against a stand-in native object. Wire it as
   [examples/wiring-the-port.md](examples/wiring-the-port.md) shows (architecture-and-boundaries owns the
   composition root).
4. **Need shared C++ or a module for non-Expo apps?** Read
   [references/turbo-modules-and-codegen.md](references/turbo-modules-and-codegen.md) and follow
   [examples/turbo-module/](examples/turbo-module/) instead of steps 2 and 3's module half.
5. **Check the contract:** `node ${CLAUDE_SKILL_DIR}/scripts/check-native-modules.mjs .`; fix every
   `FAIL` line and rerun.
6. **Prove it builds and links** ([references/testing-and-proof.md](references/testing-and-proof.md)):
   `npm test`, `npx expo-modules-autolinking resolve --platform apple --json` and `--platform android`,
   the Swift type-check, the Kotlin helper compile; then a dev client on a named iOS simulator and
   Android emulator (ios-simulator-build, android-emulator-build), every member on both, and the manual
   check for OS events.
7. **Check native libraries.** After `npm ci`:
   `node ${CLAUDE_SKILL_DIR}/scripts/check-native-libraries.mjs .` and `npx expo-doctor`. An `interop`
   library: replace it, wrap what you need in a module, or (after asking) record it in
   `contracts/native-libraries.json` from `templates/contracts/` ([examples/native-libraries.json](examples/native-libraries.json);
   [references/library-compatibility.md](references/library-compatibility.md)).
8. **Run both checks** until each prints `RESULT: PASS`.

## Definition of done

- [ ] No package covered the need, and the developer agreed to native code; the module is in
      `modules/<name>/`, written with the Expo Modules API (or a Turbo Module package for shared C++ or
      publishing), with no native source elsewhere.
- [ ] `expo-module.config.json`, podspec and Gradle file complete; one explicit `Name()` on both platforms.
- [ ] One typed, lazy, optional binding in `native-<concern>-adapter.ts`; the binding type lists exactly
      the native members and events; platform gaps optional and mapped to `unsupported`.
- [ ] Port, fake (+ test) and adapter test in Jest; `npm test` passes.
- [ ] `npx expo-modules-autolinking resolve` lists the module for apple and android; the Swift
      type-checks; a dev client ran every member on a named simulator and emulator.
- [ ] Every interop library has a current decision entry; expo-doctor's directory check is on.
- [ ] `node ${CLAUDE_SKILL_DIR}/scripts/check-native-libraries.mjs .` prints `RESULT: PASS` (after
      `npm ci`; `note:` lines name transitive interop packages with their parent, and native devDependencies).
- [ ] `node ${CLAUDE_SKILL_DIR}/scripts/check-native-modules.mjs .` prints `RESULT: PASS`

Neither checker prints SKIP lines. Each prints `NOT APPLICABLE` when `expo` (modules) or `react-native`
(libraries) is not a dependency. `check-native-libraries` exits 2 without `node_modules`.

## Anti-patterns

- **`export default requireNativeModule<...>('X')` as `create-expo-module` writes it.** It runs at
  import and throws in Jest, on web and in older binaries; bind in the adapter factory with
  `requireOptionalNativeModule`.
- **`import { NativeModules } from 'react-native'`, or `requireOptionalNativeModule` in a screen or
  hook.** Native code then reaches untested layers; take the port from `useServices()`.
- **An untyped binding, or a type that "roughly" matches.** Nothing catches `getTimeZone` versus
  `getTimeZoneId` until a user calls it; list the native members exactly.
- **A function added to Kotlin only.** It works on Android and is "not a function" on iOS; implement it
  on both, or make it optional in the binding and map the gap.
- **Business logic inside `definition()` closures.** It cannot be compiled, read or tested alone; call a
  plain helper.
- **`addObserver` in `OnStartObserving` with no `removeObserver`, or a helper's `stop()` the Module
  never calls.** Events keep arriving after the screen closed; stop in `OnStopObserving` and `OnDestroy`.
- **A second adapter that binds a module already bound.** Two copies of the contract drift apart;
  take the first adapter's port instead.
- **UIKit in a `Function`, or in an `AsyncFunction` without `.runOnQueue(.main)`.** Main Thread Checker
  warnings in debug, corrupted UI in release.
- **Swift or Kotlin beside screens through `experiments.inlineModules`.** Experimental, outside every
  rule and review; use `modules/<name>/`.
- **A Turbo Module "for performance" with no C++ to share.** The Expo Modules API is JSI-based too, with
  far less code; codegen earns its cost only for shared C++ or publishing.
- **`codegenConfig` in the app's package.json.** Its native half would live in generated folders that
  a clean prebuild deletes.
- **Silencing expo-doctor with `enabled: false` or `"/react-native-.*/"`.** It hides every future
  warning; exclude exact names with a recorded decision.
- **Testing native behaviour only in Jest.** Jest proves the mapping; only a dev build proves the OS
  sends what the module claims.

## Files in this skill

| File | What it is | Read/run when |
|---|---|---|
| [references/decisions.md](references/decisions.md) | The order of reach, Expo module vs Turbo Module vs legacy vs inline, file layout, the binding design, platform gaps, views, native changes and updates | Workflow step 1; any design question |
| [references/expo-modules-api.md](references/expo-modules-api.md) | create-expo-module output, the config, definition components, types, threads, events, errors, lifecycle, views, the JavaScript side, iOS vs Android | Workflow step 2 |
| [references/turbo-modules-and-codegen.md](references/turbo-modules-and-codegen.md) | Turbo Modules as local packages, specs, codegenConfig, native halves, linking proof, Fabric components | Workflow step 4 |
| [references/library-compatibility.md](references/library-compatibility.md) | Interop facts for React Native 0.86, classifying packages, expo-doctor's directory check, the decisions file, transitive packages | Workflow step 7 |
| [references/testing-and-proof.md](references/testing-and-proof.md) | Jest layers, the contract check, autolinking proof, Swift type-check and Kotlin compile recipes, device checks, problems and fixes | Workflow steps 3, 5 and 6 |
| `templates/modules/time-change/` | A complete local Expo module: config, podspec, Gradle file, manifest; Swift and Kotlin definitions over plain helpers (time zone, clock and day changes) | Workflow step 2 (copy the shape, or adopt it) |
| `templates/src/services/time-change/time-change-port.ts` | The port: Result failures, an unsubscribe for events | Workflow step 3 |
| `templates/src/services/time-change/native-time-change-adapter.ts` | The one binding: binding type, lazy optional lookup, error and event mapping | Workflow step 3 |
| `templates/src/services/time-change/native-time-change-adapter.test.ts` | The adapter against a stand-in native object, and the null lookup under Jest | Workflow step 3 |
| `templates/src/services/time-change/fake-time-change.ts` | The port's fake with emit, listener count and a call log | Workflow step 3 |
| `templates/src/services/time-change/fake-time-change.test.ts` | Its test | Workflow step 3 |
| `templates/contracts/native-libraries.json` | The empty decisions file | Workflow step 7 |
| `templates/contracts/native-libraries.schema.json` | Its JSON schema | Workflow step 7 |
| [examples/wiring-the-port.md](examples/wiring-the-port.md) | Services, composition root, test services, a model hook that subscribes | Workflow step 3 |
| [examples/turbo-module/](examples/turbo-module/) | A local Turbo Module package: spec, codegenConfig, root podspec, Objective-C++, Kotlin, package, shared C++ reached through JNI and CMake on Android | Workflow step 4 |
| [examples/native-libraries.json](examples/native-libraries.json) | A filled decisions file (keep and replace) | Workflow step 7 |
| `scripts/check-native-modules.mjs` | Checks modules (config, classes, build files, names, parity, threads, observers, legacy code), bindings and the contract, codegen, placement | Workflow step 5; definition of done |
| `scripts/check-native-libraries.mjs` | Classifies installed native dependencies; checks the decisions file and expo-doctor's directory check | Workflow step 7; definition of done |
| `scripts/lib/native-scan.mjs` | Swift/Kotlin class and definition reader, binding and binding-type parser, package classifier | Only when changing a checker |
| `scripts/lib/source-scan.mjs` | Dependency-free lexer (synced from the library; do not edit here) | Only when changing a checker |
| `scripts/check-lib.mjs` | Shared script helper (synced from the library; do not edit here) | Never by hand |
| `scripts/selftest.mjs` | Proves both checkers pass the templates and example and catch every planted bug | After changing a checker |
| `assets/shared.json` | Declares the synced files | When adding a shared file |
| `tests/` | The base apps and the overlay cases (one planted bug per rule) | When adding a rule |

## Related skills

- `architecture-and-boundaries` - ports, adapters, fakes, the composition root; `modules/<name>/` as an SDK only its adapter reaches.
- `expo-prebuild-and-config-plugins` - Info.plist, manifest and entitlement changes a module needs; generated folders.
- `dependency-management` - installing and pinning a native library before this skill's compatibility check.
- `expo-sdk-upgrade` - SDK moves, removed `newArchEnabled`, rechecking interop decisions.
- `unit-and-component-tests` - render helpers and test services for code above the adapter.
- `error-handling-typed-results` - the Result and failure kinds the port returns.
- `async-safety` - effect cleanup for subscriptions to module events.
- `ios-simulator-build`, `android-emulator-build` - dev builds on named devices.
- `e2e-maestro` - flows that exercise native features on both platforms.
- `ota-updates` - runtime versions: why native changes need a new binary.
- `permissions-and-system-prompts` - a module that needs a runtime permission asks through that port.
- `troubleshooting-playbook` - the wider error catalog.

Files in this skill

  • SKILL.md18.8 KB
  • assets/shared.json155 B
  • examples/native-libraries.json1.1 KB
  • examples/turbo-module/TaskChecksum.podspec821 B
  • examples/turbo-module/android/CMakeLists.txt615 B
  • examples/turbo-module/android/build.gradle783 B
  • examples/turbo-module/android/src/main/cpp/task-checksum-jni.cpp1021 B
  • examples/turbo-module/android/src/main/java/com/example/taskchecksum/NativeTaskChecksumModule.kt783 B
  • examples/turbo-module/android/src/main/java/com/example/taskchecksum/NativeTaskChecksumPackage.kt1.1 KB
  • examples/turbo-module/android/src/main/java/com/example/taskchecksum/TaskChecksumJni.kt552 B
  • examples/turbo-module/cpp/task-checksum.cpp480 B
  • examples/turbo-module/cpp/task-checksum.h432 B
  • examples/turbo-module/ios/RCTNativeTaskChecksum.h222 B
  • examples/turbo-module/ios/RCTNativeTaskChecksum.mm1.1 KB
  • examples/turbo-module/package.json474 B
  • examples/turbo-module/src/NativeTaskChecksum.ts686 B
  • examples/wiring-the-port.md2.4 KB
  • references/decisions.md15.5 KB
  • references/expo-modules-api.md14 KB
  • references/library-compatibility.md10.8 KB

Attribution

Is this your skill, or is something wrong with this listing? Request removal or report an issue. Author removals are honored within 72 hours.

Comments

Loading comments…

Newsletter

Get the weekly list of safe new skills, and the ones we pulled.