Modern, lightweight navigation for React Native and Web, built on top of react-native-screens.
This library is URL-first: you navigate by paths (/users/42?tab=posts), and the router derives params and query for screens.
- Stacks: predictable stack-based navigation
- Tabs:
TabBarwith native + web renderers (or custom tab bar) - Split view: master/details navigation (
SplitView) - Modals & sheets: via
stackPresentation(modal,sheet, …) - Controllers: async/guarded navigation (only present when ready)
- Web History integration: keeps Router state in sync with
pushState,replaceState,popstate - Dynamic root: swap root navigation tree at runtime (
router.setRoot) - Type-safe hooks:
useParams,useQueryParams,useRoute,useCurrentRoute
yarn add @sigmela/router react-native-screens
# optional (required only if you use sheet presentation)
yarn add @sigmela/native-sheetreactreact-nativereact-native-screens(>=4.18.0)@sigmela/native-sheet(>=0.0.1) — only if you use sheets
On web you must import the bundled stylesheet once:
import '@sigmela/router/styles.css';import {
Navigation,
NavigationStack,
Router,
useParams,
useQueryParams,
useRouter,
} from '@sigmela/router';
function HomeScreen() {
const router = useRouter();
return (
<Button
title="Open details"
onPress={() => router.navigate('/details/42?from=home')}
/>
);
}
function DetailsScreen() {
const { id } = useParams<{ id: string }>();
const { from } = useQueryParams<{ from?: string }>();
return <Text>Details: id={id}, from={from ?? 'n/a'}</Text>;
}
const rootStack = new NavigationStack()
.addScreen('/', HomeScreen, { header: { title: 'Home' } })
.addScreen('/details/:id', DetailsScreen, { header: { title: 'Details' } });
const router = new Router({ roots: { app: rootStack }, root: 'app' });
export default function App() {
return <Navigation router={router} />;
}import { Navigation, NavigationStack, Router, TabBar } from '@sigmela/router';
const homeStack = new NavigationStack().addScreen('/', HomeScreen);
const catalogStack = new NavigationStack()
.addScreen('/catalog', CatalogScreen)
.addScreen('/catalog/products/:productId', ProductScreen);
const tabBar = new TabBar({ initialIndex: 0 })
.addTab({ key: 'home', stack: homeStack, title: 'Home' })
.addTab({ key: 'catalog', stack: catalogStack, title: 'Catalog' });
const router = new Router({ roots: { app: tabBar }, root: 'app' });
export default function App() {
return <Navigation router={router} />;
}In the example app, tabs are mounted as a screen inside a root NavigationStack. This lets you keep tab navigation plus define modals/overlays at the root level.
import { NavigationStack, TabBar } from '@sigmela/router';
const homeStack = new NavigationStack().addScreen('/', HomeScreen);
const catalogStack = new NavigationStack().addScreen('/catalog', CatalogScreen);
const tabBar = new TabBar()
.addTab({
key: 'home',
stack: homeStack,
title: 'Home',
icon: require('./assets/home.png'),
})
.addTab({
key: 'catalog',
stack: catalogStack,
title: 'Catalog',
icon: require('./assets/catalog.png'),
});
// Root stack hosts the tab bar AND top-level modals/overlays.
export const rootStack = new NavigationStack()
.addScreen('/', tabBar)
.addModal('/auth', AuthScreen, { header: { title: 'Login', hidden: true } })
.addModal('*?modal=promo', PromoModal);A NavigationStack defines a set of routes and how to match them.
const stack = new NavigationStack({ header: { largeTitle: true } })
.addScreen('/feed', FeedScreen)
.addScreen('/feed/:id', FeedItemScreen)
.addModal('/auth', AuthScreen)
.addSheet('/settings', SettingsSheet);Key methods:
addScreen(pathPattern, componentOrNode, options?)addModal(pathPattern, componentOrStack, options?)(shorthand forstackPresentation: 'modal')addSheet(pathPattern, componentOrStack, options?)(shorthand forstackPresentation: 'sheet')addStack(prefixOrStack, maybeStack?)— compose nested stacks under a prefix
You can wrap an entire stack with a React context provider by passing a provider option:
import { ThemeProvider } from './theme';
const stack = new NavigationStack({
header: { largeTitle: true },
provider: ThemeProvider,
})
.addScreen('/feed', FeedScreen)
.addScreen('/feed/:id', FeedItemScreen);The provider component wraps the entire stack renderer, making the context available to all screens in the stack. This is useful for:
- Theme providers: Apply theme context to all screens
- Auth providers: Share authentication state across screens
- Localization: Provide i18n context to the entire stack
Composing multiple providers:
If you need multiple providers, create a composed component:
const ComposedProvider = ({ children }) => (
<ThemeProvider>
<AuthProvider>
<I18nProvider>
{children}
</I18nProvider>
</AuthProvider>
</ThemeProvider>
);
const stack = new NavigationStack({ provider: ComposedProvider })
.addScreen('/', HomeScreen);Important: The provider should be a stable reference (not an inline arrow function) to avoid unnecessary re-renders.
You can pass an entire NavigationStack to addModal() or addSheet() to create a multi-screen flow inside a modal:
// Define a flow with multiple screens
const emailVerifyStack = new NavigationStack()
.addScreen('/verify', EmailInputScreen)
.addScreen('/verify/sent', EmailSentScreen);
// Mount the entire stack as a modal
const rootStack = new NavigationStack()
.addScreen('/', HomeScreen)
.addModal('/verify', emailVerifyStack);How it works:
- Navigating to
/verifyopens the modal withEmailInputScreen - Inside the modal,
router.navigate('/verify/sent')pushesEmailSentScreenwithin the same modal router.goBack()navigates back inside the modal stackrouter.dismiss()closes the entire modal from any depth
Example screen with navigation inside modal:
function EmailInputScreen() {
const router = useRouter();
return (
<View>
<Button title="Next" onPress={() => router.navigate('/verify/sent')} />
<Button title="Close" onPress={() => router.dismiss()} />
</View>
);
}
function EmailSentScreen() {
const router = useRouter();
return (
<View>
<Button title="Back" onPress={() => router.goBack()} />
<Button title="Done" onPress={() => router.dismiss()} />
</View>
);
}This pattern works recursively — you can nest stacks inside stacks to any depth.
The Router holds navigation state and performs path matching.
const router = new Router({
roots: { app: root }, // NavigationNode (NavigationStack, TabBar, SplitView, ...)
root: 'app',
screenOptions, // optional defaults
debug, // optional
});Navigation:
router.navigate(path)— pushrouter.replace(path, dedupe?)— replace top of the active stackrouter.goBack()— pop top of the active stackrouter.dismiss()— close the nearest modal or sheet (including all screens in a modal stack)router.reset(path)— web-only: rebuild Router state as if app loaded atpathrouter.setRoot(rootKey, { transition? })— swap root at runtime (rootKeyfromconfig.roots)
State/subscriptions:
router.getState()→{ history: HistoryItem[] }router.getActiveRoute()→ActiveRoute | nullrouter.subscribe(cb)— notify on any history changerouter.subscribeStack(stackId, cb)— notify when a particular stack slice changesrouter.subscribeRoot(cb)— notify when root is replaced viasetRootrouter.getStackHistory(stackId)— slice of history for a stack
TabBar is a container node that renders one tab at a time.
const tabBar = new TabBar({ component: CustomTabBar, initialIndex: 0 })
.addTab({ key: 'home', stack: homeStack, title: 'Home' })
.addTab({ key: 'search', screen: SearchScreen, title: 'Search' });Key methods:
addTab({ key, stack?, node?, screen?, prefix?, title?, icon?, selectedIcon?, ... })onIndexChange(index)— switch active tabsetBadge(index, badge | null)setTabBarConfig(partialConfig)getState()andsubscribe(cb)
Notes:
- Exactly one of
stack,node,screenmust be provided. - Use
prefixto mount a tab's routes under a base path (e.g./mail).
Web behavior note:
- The built-in web tab bar renderer resets Router history on tab switch (to keep URL and Router state consistent) using
router.reset(firstRoutePath).
SplitView renders two stacks: primary and secondary.
- On native,
secondaryoverlaysprimarywhen it has at least one screen in its history. - On web, the layout becomes side-by-side at a fixed breakpoint (
minWidth, default640px).
import { NavigationStack, SplitView, TabBar } from '@sigmela/router';
const master = new NavigationStack().addScreen('/', ThreadsScreen);
const detail = new NavigationStack().addScreen('/:threadId', ThreadScreen);
const splitView = new SplitView({
minWidth: 640,
primary: master,
secondary: detail,
primaryMaxWidth: 390,
});
// Mount SplitView directly as a tab (no wrapper stack needed).
const tabBar = new TabBar()
.addTab({ key: 'mail', node: splitView, prefix: '/mail', title: 'Mail' })
.addTab({ key: 'settings', stack: settingsStack, title: 'Settings' });Controllers let you delay/guard navigation. A route can be registered as:
import { createController } from '@sigmela/router';
const UserDetails = {
component: UserDetailsScreen,
controller: createController<{ userId: string }, { tab?: string }>(
async ({ params, query }, present) => {
const ok = await checkAuth();
if (!ok) {
router.replace('/login', true);
return;
}
const user = await fetchUser(params.userId);
present({ user, tab: query.tab });
}
),
};
stack.addScreen('/users/:userId', UserDetails);If you never call present(), the screen is not pushed/replaced.
Access the router instance.
Subscribes to router.getActiveRoute().
Returns ActiveRoute | null (shape from src/types.ts):
type ActiveRoute = {
routeId: string;
stackId?: string;
tabIndex?: number;
path?: string;
params?: Record<string, unknown>;
query?: Record<string, unknown>;
} | null;Returns params/query for the current screen (from route context).
Returns route-local context for the current screen:
type RouteLocalContextValue = {
presentation: StackPresentationTypes;
params?: Record<string, unknown>;
query?: Record<string, unknown>;
pattern?: string;
path?: string;
};Returns the nearest TabBar from context (only inside tab screens).
import { useTabBar } from '@sigmela/router';
function ScreenInsideTabs() {
const tabBar = useTabBar();
return (
<Button
title="Go to second tab"
onPress={() => tabBar.onIndexChange(1)}
/>
);
}On web, Router integrates with the browser History API using custom events:
router.navigate('/x')writeshistory.pushState({ __srPath: ... })router.replace('/x')writeshistory.replaceState({ __srPath: ... })- Browser back/forward triggers
popstateand Router updates its state accordingly
Important behavioral detail:
router.goBack()does not callhistory.back(). It pops Router state and updates the URL viareplaceState(so it doesn’t grow/rewind the browser stack).
If a route has screenOptions.syncWithUrl = false, Router stores the “real” router path in history.state.__srPath while keeping the visible URL unchanged.
MIT