Router

@ng-native/router is not a navigation library of its own. It is @angular/router - the same Router, the same route config, the same guards, resolvers and deep links - pointed at react-native-screens instead of the DOM. Reach for it as soon as an app has more than one screen: its outlets create and destroy real RNSScreen views, so a push is a genuine native stack transition with the platform's own animation, swipe-back gesture, and a real UINavigationController/Fragment underneath it, not something drawn to look like one.

Setting it up

npm install @ng-native/router @angular/router
npx expo install react-native-screens

react-native-screens is the native side: every stack, screen, header and tab bar here is one of its views. Expo Go bundles it, but a development or release build only links a native module the app lists in its own package.json, so without it those builds render Unimplemented component where the navigation should be.

import { AppRegistry } from 'react-native';
import { withComponentInputBinding } from '@angular/router';
import { mount } from '@ng-native/platform';
import { getFabricUIManager } from '@ng-native/fabric';
import { provideNativeRouter } from '@ng-native/router';
import { routes } from './app/app.routes.ts';
import { App } from './app/app.ts';

AppRegistry.registerRunnable('main', ({ rootTag }) => {
  mount(Number(rootTag), App, getFabricUIManager(), {
    providers: [provideNativeRouter(routes, withComponentInputBinding())],
  });
});

provideNativeRouter(routes, ...features) is provideRouter - it takes the same routes and the same router features, and calls it - plus what native needs on top: a PlatformLocation backed by an in-memory history instead of the browser's, a RouteReuseStrategy that detaches a screen instead of destroying it (what keeps a pushed-away screen's scroll position and text input alive), and NativeNavigation. Android's hardware back button and deep links come from @ng-native/device's HardwareBack and DeepLinks and need no wiring here - both already fall back to doing nothing off a device.

Any app or web page on the device can open any of your routes by deep link, with whatever params and query it likes. A route that acts as it arrives (deletes, pays, signs out, changes a setting) needs a guard, or the person's confirmation on the page, rather than trusting the URL it came by.

The native options are passed the same way: withLinkParent for deep links, and withHeaderDefaults and withTabDefaults for how every header and tab bar looks when a screen does not say.

The routes can come from files instead of an array: fileRoutes(pages) routes each file in src/app/pages by its path, with Analog's file names. See File routes.

withComponentInputBinding() is opt-in here exactly as it is on the web: with it, a :id param, a query param or resolved data arrives as the page's id input; without it, both outlets leave a page's inputs alone, as Angular's RouterOutlet does, and the page reads ActivatedRoute instead. The feature's options are not read, though: query params always bind, and an input with no matching key is set to undefined, which are its defaults.

Every other router feature is passed through unchanged, preloading included. A lazy route's code is otherwise loaded on the first navigation to it, which on the dev server is a request to Metro and a pause before the page appears. withPreloading(PreloadAllModules) loads every lazy route right after start-up instead:

import { PreloadAllModules, withComponentInputBinding, withPreloading } from '@angular/router';
import { provideNativeRouter } from '@ng-native/router';
import { routes } from './app/app.routes.ts';

const router = provideNativeRouter(
  routes,
  withComponentInputBinding(),
  withPreloading(PreloadAllModules),
);

The shell

A route config is an ordinary Routes array - loadComponent, children, guards, all of it. What differs is the shell: instead of a plain <router-outlet>, the root component renders a <native-stack-outlet>, and its host element becomes the stack:

import { Component } from '@angular/core';
import { SafeAreaProvider } from '@ng-native/components';
import { NativeStackOutlet } from '@ng-native/router';

@Component({
  selector: 'app-root',
  imports: [NativeStackOutlet, SafeAreaProvider],
  template: `
    <safe-area-provider>
      <native-stack-outlet />
    </safe-area-provider>
  `,
  styles: `
    :host {
      flex: 1;
    }
  `,
})
export class App {}

Every route's component is created directly inside its own screen, so screens below the top of the stack stay mounted rather than torn down - which is what makes their scroll offset, cursor position and keyboard focus survive a push. A screen popped by a swipe-back gesture or the Android back button is reported to the router as a real navigation, so the URL and the visible stack never disagree.

A page's host element is its screen, so a style on :host (a background, padding, how it lays out its children) styles the screen, and a :host > ... selector matches the elements at the root of the page's template. On iOS 26 and later the screen holds the page's content in a safe area view that starts it below an opaque header, and the layout :host gives its children (flex-direction, justify-content, align-items, gap) is committed on that view, so it lays them out as it does on every other platform.

Anything a route path can express - routerLink, router.navigate(), guards, resolvers - keeps working exactly as it does on the web, because it is still the same Router. For a plain push, nativeRouterLink is the native equivalent of routerLink:

<pressable [nativeRouterLink]="['/detail', item.id]"><text>Open</text></pressable>

When a page fails to load

A lazy route whose code cannot be loaded fails its navigation the way it does on the web: the router emits a NavigationError and stays on the page it was on. A lazily loaded chunk the dev server cannot serve is one cause, and a release build, which puts every lazy route in the one bundle, has none of these. Whichever way the navigation started, the app hears it in one place: Angular's withNavigationErrorHandler, passed to provideNativeRouter beside the other router features. It runs in an injection context, so it can tell the rest of the app:

import { Service, inject, signal } from '@angular/core';
import { withNavigationErrorHandler, type Routes } from '@angular/router';
import { provideNativeRouter } from '@ng-native/router';

const routes: Routes = [
  // The app's routes, lazy ones among them.
];

/** The page that did not load, for a banner that says so and offers to try again. */
@Service()
export class FailedPage {
  readonly url = signal<string | null>(null);
}

const router = provideNativeRouter(
  routes,
  withNavigationErrorHandler((error) => inject(FailedPage).url.set(error.url)),
);

A banner bound to FailedPage.url can show the message and call Router.navigateByUrl(url) to try again. router.events carries the same NavigationError for code that watches every navigation instead.

Around that one handler, each way into a page does what its caller expects:

  • NativeNavigation.push() and present(), and Router.navigate(), reject their promise, so the code that asked can react as well.
  • A tab tap that fails leaves the tab bar on the tab the router is still on, and the error also reaches the app's ErrorHandler.
  • A deep link that fails, at launch or while the app runs, reaches the ErrorHandler too, rather than going unhandled.

From here, Screens and navigation covers everything a URL alone cannot express - replacing a screen, presenting a modal or sheet, resetting the stack - through NativeNavigation. The native header covers <native-header> and the slots <native-header-item> places content in. Tabs covers <native-tabs-outlet> and declaring a bar as content rather than as config. File routes covers fileRoutes, the routes made from the files in src/app/pages.

Angular components as native iOS and Android views, built and shipped with Expo.

An alpha. MIT licensed. Sponsor its development.

An independent project, not affiliated with or endorsed by Google, the Angular team or Expo. Angular is a trademark of Google LLC.