AutoGineer summary
Written by AutoGineer from the skill’s own files, not by the author
Expo Router provides a file-based routing system for Expo applications, enabling native navigation stacks, tabs, modals, and form sheets. It leverages a directory-based structure where the file system defines the app's route hierarchy. This skill covers route organization, dynamic routing, layout management, and native UI integration for headers, search bars, and context menus to ensure a consistent iOS and Android experience.
What it does
- Defines app navigation via file-based routing in the app directory.
- Configures native stacks, tabs, and modal presentations using _layout.tsx files.
- Implements dynamic routes and catch-all segments for flexible URL handling.
- Integrates native UI elements like search bars, headers, and context menus.
- Supports advanced presentation styles including form sheets with custom detents.
- Provides type-safe navigation and link previews for enhanced user interaction.
When to use it
- When building a new Expo application requiring native navigation.
- When migrating from standard React Navigation to a file-based routing architecture.
- When implementing complex navigation patterns like nested stacks, tabs, or modals.
- When you need to leverage native iOS/Android navigation features like form sheets or search bars.
Advantages
- Simplifies navigation logic by mapping the file system directly to app routes.
- Ensures native performance and look-and-feel across platforms.
- Promotes clean project structure by separating route definitions from business logic.
- Provides built-in support for native UI components like context menus and sheet detents.
Usage examples
- “How do I create a modal screen in my Expo Router app?”
- You define the modal in your _layout.tsx using Stack.Screen and set the presentation option to 'modal'.
- “How can I implement a form sheet that slides up from the bottom?”
- Configure a Stack.Screen with presentation: 'formSheet', define sheetAllowedDetents, and set the contentStyle background to transparent.
- “How do I add a context menu to a link in my app?”
- Wrap your component in a Link.Trigger and add a Link.Menu component containing Link.MenuAction items inside the Link.
- “What is the best way to organize routes for a tabbed application?”
- Use a root _layout.tsx with NativeTabs, then nest individual Stack navigators within group routes for each tab.
Install
Download the skill as a ZIP:
https://autogineer.com/agent-skills/expo/expo-router/downloadUnzip it into the cross-client skills folder of your project:
mkdir -p .agents/skills && unzip expo-router.zip -d .agents/skills
Documentation by Expo
Expo Router Navigation
Navigation and routing for Expo Router apps. For screen styling, colors, controls, media, and visual effects, use the expo-native-ui skill; for motion and gestures, use expo-animation.
References
Consult these resources as needed:
references/
route-structure.md Route conventions, dynamic routes, groups, folder organization
tabs.md NativeTabs, migration from JS tabs, iOS 26 features
toolbar-and-headers.md Stack headers and toolbar buttons, menus, search (iOS only)
form-sheet.md Form sheets in expo-router: configuration, footers and background interaction.
search.md Search bar with headers, useSearch hook, filtering patterns
zoom-transitions.md Apple Zoom: fluid zoom transitions with Link.AppleZoom (iOS 18+)Code Style
Always use kebab-case for file names, e.g.
comment-card.tsxAlways remove old route files when moving or restructuring navigation
Never use special characters in file names
Configure tsconfig.json with path aliases, and prefer aliases over relative imports for refactors.
Routes
See ./references/route-structure.md for detailed route conventions.
Routes belong in the
appdirectory.Never co-locate components, types, or utilities in the app directory. This is an anti-pattern.
Ensure the app always has a route that matches "/", it may be inside a group route.
Library Preferences
Colorfromexpo-routerfor native semantic colors, not rawPlatformColor(type-safe, auto-adapts to light/dark). Seeexpo-native-uifor the full color palette pattern.In SDK 56+, never import from
@react-navigation/*directly — useexpo-router/react-navigationinstead (covers@react-navigation/native,/core,/elements,/routers)
Behavior
Prefer
Stack.SearchBarto add a search bar to a screen
Navigation
Link
Use <Link href="/path" /> from 'expo-router' for navigation between routes.
import { Link } from 'expo-router';
// Basic link
<Link href="/path" />
// Wrapping custom components
<Link href="/path" asChild>
<Pressable>...</Pressable>
</Link>Whenever possible, include a <Link.Preview> to follow iOS conventions. Add context menus and previews frequently to enhance navigation.
Stack
ALWAYS use
_layout.tsxfiles to define stacksUse Stack from 'expo-router/stack' for native navigation stacks
Page Title
Set the page title with Stack.Title:
<Stack.Title>Home</Stack.Title>Context Menus
Add long press context menus to Link components:
import { Link } from "expo-router";
<Link href="/settings" asChild>
<Link.Trigger>
<Pressable>
<Card />
</Pressable>
</Link.Trigger>
<Link.Menu>
<Link.MenuAction
title="Share"
icon="square.and.arrow.up"
onPress={handleSharePress}
/>
<Link.MenuAction
title="Block"
icon="nosign"
destructive
onPress={handleBlockPress}
/>
<Link.Menu title="More" icon="ellipsis">
<Link.MenuAction title="Copy" icon="doc.on.doc" onPress={() => {}} />
<Link.MenuAction
title="Delete"
icon="trash"
destructive
onPress={() => {}}
/>
</Link.Menu>
</Link.Menu>
</Link>;Link Previews
Use link previews frequently to enhance navigation:
<Link href="/settings">
<Link.Trigger>
<Pressable>
<Card />
</Pressable>
</Link.Trigger>
<Link.Preview />
</Link>Link preview can be used with context menus.
Modal
Present a screen as a modal:
<Stack.Screen name="modal" options={{ presentation: "modal" }} />Prefer this to building a custom modal component.
Sheet
Present a screen as a dynamic form sheet:
<Stack.Screen
name="sheet"
options={{
presentation: "formSheet",
sheetGrabberVisible: true,
sheetAllowedDetents: [0.5, 1.0],
contentStyle: { backgroundColor: "transparent" },
}}
/>Using
contentStyle: { backgroundColor: "transparent" }makes the background liquid glass on iOS 26+.
Common route structure
A standard app layout with tabs and stacks inside each tab:
app/
_layout.tsx — <NativeTabs />
(index,search)/
_layout.tsx — <Stack />
index.tsx — Main list
search.tsx — Search view// app/_layout.tsx
import { NativeTabs } from "expo-router/unstable-native-tabs";
import { ThemeProvider, DarkTheme, DefaultTheme } from "expo-router/react-navigation";
import { useColorScheme } from "react-native";
export default function Layout() {
const colorScheme = useColorScheme();
return (
<ThemeProvider value={colorScheme === "dark" ? DarkTheme : DefaultTheme}>
<NativeTabs>
<NativeTabs.Trigger name="(index)">
<NativeTabs.Trigger.Icon sf="list.dash" md="list" />
<NativeTabs.Trigger.Label>Items</NativeTabs.Trigger.Label>
</NativeTabs.Trigger>
<NativeTabs.Trigger name="(search)" role="search" />
</NativeTabs>
</ThemeProvider>
);
}Create a shared group route so both tabs can push common screens:
// app/(index,search)/_layout.tsx
import { Stack } from "expo-router/stack";
import { colors } from "@/theme/colors";
export default function Layout({ segment }) {
const screen = segment.match(/\((.*)\)/)?.[1]!;
const titles: Record<string, string> = { index: "Items", search: "Search" };
return (
<Stack
screenOptions={{
headerTransparent: true,
headerShadowVisible: false,
headerLargeTitleShadowVisible: false,
headerLargeStyle: { backgroundColor: "transparent" },
headerTitleStyle: { color: colors.label },
headerLargeTitleEnabled: true,
headerBlurEffect: "none",
headerBackButtonDisplayMode: "minimal",
}}
>
<Stack.Screen name={screen} options={{ title: titles[screen] }} />
<Stack.Screen name="i/[id]" options={{ headerLargeTitleEnabled: false }} />
</Stack>
);
}headerLargeTitleEnabled is the SDK 56+ option name; older SDKs use headerLargeTitle, which is deprecated upstream.
Submitting Feedback
If you encounter errors, misleading or outdated information in this skill, report it so Expo can improve:
npx --yes @expo/agent-cli@latest feedback --category skills --subject "expo-router" "<actionable feedback>"Only submit when you have something specific and actionable to report. Include as much relevant context as possible.
If an agent repeatedly fails at an Expo task or the user has to step in, submit a brief report of the task, expected result, and what went wrong using --category evals and the relevant Expo package or command as --subject.
Files
- ATTRIBUTION.mdAdded by AutoGineer809 B
- LICENSELicence file added by AutoGineer1.1 KiB
- SKILL.md6.9 KiB
- agents/openai.yaml422 B
- references/form-sheet.md6.2 KiB
- references/route-structure.md5.0 KiB
- references/search.md5.1 KiB
- references/tabs.md13.0 KiB
- references/toolbar-and-headers.md7.9 KiB
- references/zoom-transitions.md4.7 KiB
references/tabs.md
Native Tabs
Always prefer NativeTabs from 'expo-router/unstable-native-tabs' for the best iOS experience.
SDK 54+. SDK 55 recommended.
SDK Compatibility
| Aspect | SDK 54 | SDK 55+ |
|---|---|---|
| Import | import { NativeTabs, Icon, Label, Badge, VectorIcon } | import { NativeTabs } only |
| Icon | <Icon sf="house.fill" /> | <NativeTabs.Trigger.Icon sf="house.fill" /> |
| Label | <Label>Home</Label> | <NativeTabs.Trigger.Label>Home</NativeTabs.Trigger.Label> |
| Badge | <Badge>9+</Badge> | <NativeTabs.Trigger.Badge>9+</NativeTabs.Trigger.Badge> |
| Android icons | drawable prop | md prop (Material Symbols) |
All examples below use SDK 55 syntax. For SDK 54, replace NativeTabs.Trigger.Icon/Label/Badge with standalone Icon, Label, Badge imports.
Basic Usage
import { NativeTabs } from "expo-router/unstable-native-tabs";
export default function TabLayout() {
return (
<NativeTabs minimizeBehavior="onScrollDown">
<NativeTabs.Trigger name="index">
<NativeTabs.Trigger.Icon sf="house.fill" md="home" />
<NativeTabs.Trigger.Label>Home</NativeTabs.Trigger.Label>
<NativeTabs.Trigger.Badge>9+</NativeTabs.Trigger.Badge>
</NativeTabs.Trigger>
<NativeTabs.Trigger name="settings">
<NativeTabs.Trigger.Icon sf="gear" md="settings" />
<NativeTabs.Trigger.Label>Settings</NativeTabs.Trigger.Label>
</NativeTabs.Trigger>
<NativeTabs.Trigger name="(search)" role="search">
<NativeTabs.Trigger.Label>Search</NativeTabs.Trigger.Label>
</NativeTabs.Trigger>
</NativeTabs>
);
}Rules
You must include a trigger for each tab
The
NativeTabs.Trigger'name' must match the route name, including parentheses (e.g.<NativeTabs.Trigger name="(search)">)Prefer search tab to be last in the list so it can combine with the search bar
Use the 'role' prop for common tab types
Tabs must be static — no dynamic addition/removal at runtime (remounts navigator, loses state)
Platform Features
Native Tabs use platform-specific tab bar implementations:
iOS 26+: Liquid glass effects with system-native appearance
Android: Material 3 bottom navigation
Better performance and native feel
Icon Component
// SF Symbol (iOS) + Material Symbol (Android)
<NativeTabs.Trigger.Icon sf="house.fill" md="home" />
// State variants
<NativeTabs.Trigger.Icon sf={{ default: "house", selected: "house.fill" }} md="home" />
// Custom image
<NativeTabs.Trigger.Icon src={require('./icon.png')} />
// Xcode asset catalog — iOS only (SDK 55+)
<NativeTabs.Trigger.Icon xcasset="home-icon" />
<NativeTabs.Trigger.Icon xcasset={{ default: "home-outline", selected: "home-filled" }} />
// Rendering mode — iOS only (SDK 55+)
<NativeTabs.Trigger.Icon src={require('./icon.png')} renderingMode="template" />
<NativeTabs.Trigger.Icon src={require('./gradient.png')} renderingMode="original" />renderingMode: "template" applies tint color (single-color icons), "original" preserves source colors (gradients). Android always uses original.
Label & Badge
// Label
<NativeTabs.Trigger.Label>Home</NativeTabs.Trigger.Label>
<NativeTabs.Trigger.Label hidden>Home</NativeTabs.Trigger.Label> {/* icon-only tab */}
// Badge
<NativeTabs.Trigger.Badge>9+</NativeTabs.Trigger.Badge>
<NativeTabs.Trigger.Badge /> {/* dot indicator */}iOS 26 Features
Liquid Glass Tab Bar
The tab bar automatically adopts liquid glass appearance on iOS 26+.
Minimize on Scroll
<NativeTabs minimizeBehavior="onScrollDown">Search Tab
<NativeTabs.Trigger name="(search)" role="search">
<NativeTabs.Trigger.Label>Search</NativeTabs.Trigger.Label>
</NativeTabs.Trigger>Note: Place search tab last for best UX.
Role Prop
Use semantic roles for special tab types:
<NativeTabs.Trigger name="search" role="search" />
<NativeTabs.Trigger name="favorites" role="favorites" />
<NativeTabs.Trigger name="more" role="more" />Available roles: search | more | favorites | bookmarks | contacts | downloads | featured | history | mostRecent | mostViewed | recents | topRated
Customization
Tint Color
<NativeTabs tintColor="#007AFF">Dynamic Colors (iOS)
Use DynamicColorIOS for colors that adapt to liquid glass:
import { DynamicColorIOS, Platform } from 'react-native';
const adaptiveBlue = Platform.select({
ios: DynamicColorIOS({ light: '#007AFF', dark: '#0A84FF' }),
default: '#007AFF',
});
<NativeTabs tintColor={adaptiveBlue}>Conditional Tabs
<NativeTabs.Trigger name="admin" hidden={!isAdmin}>
<NativeTabs.Trigger.Label>Admin</NativeTabs.Trigger.Label>
<NativeTabs.Trigger.Icon sf="shield.fill" md="shield" />
</NativeTabs.Trigger>Don't hide the tabs when they are visible - toggling visibility remounts the navigator; Do it only during the initial render.
Note: Hidden tabs cannot be navigated to!
Behavior Options
<NativeTabs.Trigger
name="home"
disablePopToTop // Don't pop stack when tapping active tab
disableScrollToTop // Don't scroll to top when tapping active tab
disableAutomaticContentInsets // Opt out of automatic safe area insets (SDK 55+)
>Hidden Tab Bar (SDK 55+)
Use hidden prop on NativeTabs to hide the entire tab bar dynamically:
<NativeTabs hidden={isTabBarHidden}>{/* triggers */}</NativeTabs>Bottom Accessory (SDK 55+)
NativeTabs.BottomAccessory renders content above the tab bar (iOS 26+). Uses usePlacement() to adapt between 'regular' and 'inline' layouts.
Important: Two instances render simultaneously — store state outside the component (props, context, or external store).
import { NativeTabs } from "expo-router/unstable-native-tabs";
import { useState } from "react";
import { Pressable, Text, View } from "react-native";
function MiniPlayer({
isPlaying,
onToggle,
}: {
isPlaying: boolean;
onToggle: () => void;
}) {
const placement = NativeTabs.BottomAccessory.usePlacement();
if (placement === "inline") {
return (
<Pressable onPress={onToggle}>
<SymbolView name={isPlaying ? "pause.fill" : "play.fill"} />
</Pressable>
);
}
return <View>{/* full player UI */}</View>;
}
export default function TabLayout() {
const [isPlaying, setIsPlaying] = useState(false);
return (
<NativeTabs>
<NativeTabs.BottomAccessory>
<MiniPlayer
isPlaying={isPlaying}
onToggle={() => setIsPlaying(!isPlaying)}
/>
</NativeTabs.BottomAccessory>
<NativeTabs.Trigger name="index">
<NativeTabs.Trigger.Icon sf="house.fill" md="home" />
<NativeTabs.Trigger.Label>Home</NativeTabs.Trigger.Label>
</NativeTabs.Trigger>
</NativeTabs>
);
}Safe Area Handling (SDK 55+)
SDK 55 handles safe areas automatically:
Android: Content wrapped in SafeAreaView (bottom inset)
iOS: First ScrollView gets automatic
contentInsetAdjustmentBehavior
To opt out per-tab, use disableAutomaticContentInsets and manage manually:
<NativeTabs.Trigger name="index" disableAutomaticContentInsets>
<NativeTabs.Trigger.Label>Home</NativeTabs.Trigger.Label>
</NativeTabs.Trigger>// In the screen
import { SafeAreaView } from "react-native-screens/experimental";
export default function HomeScreen() {
return (
<SafeAreaView edges={{ bottom: true }} style={{ flex: 1 }}>
{/* content */}
</SafeAreaView>
);
}Using Vector Icons
If you must use @expo/vector-icons instead of SF Symbols:
import { NativeTabs } from "expo-router/unstable-native-tabs";
import Ionicons from "@expo/vector-icons/Ionicons";
<NativeTabs.Trigger name="home">
<NativeTabs.Trigger.VectorIcon vector={Ionicons} name="home" />
<NativeTabs.Trigger.Label>Home</NativeTabs.Trigger.Label>
</NativeTabs.Trigger>Prefer SF Symbols + md prop over vector icons for native feel.
If you are using SDK 55 and later use the md prop to specify Material Symbols used on Android.
Structure with Stacks
Native tabs don't render headers. Nest Stacks inside each tab for navigation headers:
// app/(tabs)/_layout.tsx
import { NativeTabs } from "expo-router/unstable-native-tabs";
export default function TabLayout() {
return (
<NativeTabs>
<NativeTabs.Trigger name="(home)">
<NativeTabs.Trigger.Label>Home</NativeTabs.Trigger.Label>
<NativeTabs.Trigger.Icon sf="house.fill" md="home" />
</NativeTabs.Trigger>
</NativeTabs>
);
}
// app/(tabs)/(home)/_layout.tsx
import Stack from "expo-router/stack";
export default function HomeStack() {
return (
<Stack>
<Stack.Screen
name="index"
options={{ title: "Home", headerLargeTitleEnabled: true }}
/>
<Stack.Screen name="details" options={{ title: "Details" }} />
</Stack>
);
}Custom Web Layout
Use platform-specific files for separate native and web tab layouts:
app/
_layout.tsx # NativeTabs for iOS/Android
_layout.web.tsx # Headless tabs for web (expo-router/ui)Or extract to a component: components/app-tabs.tsx + components/app-tabs.web.tsx.
Migration from JS Tabs
Before (JS Tabs)
import { Tabs } from "expo-router";
<Tabs>
<Tabs.Screen
name="index"
options={{
title: "Home",
tabBarIcon: ({ color }) => <IconSymbol name="house.fill" color={color} />,
tabBarBadge: 3,
}}
/>
</Tabs>;After (Native Tabs)
import { NativeTabs } from "expo-router/unstable-native-tabs";
<NativeTabs>
<NativeTabs.Trigger name="index">
<NativeTabs.Trigger.Label>Home</NativeTabs.Trigger.Label>
<NativeTabs.Trigger.Icon sf="house.fill" md="home" />
<NativeTabs.Trigger.Badge>3</NativeTabs.Trigger.Badge>
</NativeTabs.Trigger>
</NativeTabs>;Key Differences
| JS Tabs | Native Tabs |
|---|---|
<Tabs.Screen> | <NativeTabs.Trigger> |
options={{ title }} | <NativeTabs.Trigger.Label> |
options={{ tabBarIcon }} | <NativeTabs.Trigger.Icon> |
tabBarBadge option | <NativeTabs.Trigger.Badge> |
| Props-based API | Component-based API |
| Headers built-in | Nest <Stack> for headers |
Limitations
Android: Maximum 5 tabs (Material Design constraint)
Nesting: Native tabs cannot nest inside other native tabs
Tab bar height: Cannot be measured programmatically
FlatList transparency: Use
disableTransparentOnScrollEdgeto fix issuesDynamic tabs: Tabs must be static; changes remount navigator and lose state
Keyboard Handling (Android)
Configure in app.json:
{
"expo": {
"android": {
"softwareKeyboardLayoutMode": "resize"
}
}
}Common Issues
Icons not showing on Android: Add
mdprop (SDK 55) or use VectorIconHeaders missing: Nest a Stack inside each tab group
Trigger name mismatch:
namemust match exact route name including parenthesesBadge not visible: Badge must be a child of Trigger, not a prop
Tab bar transparent on iOS 18 and earlier: If the screen uses a
ScrollVieworFlatList, make sure it is the first opaque child of the screen component. If it needs to be wrapped in anotherView, ensure the wrapper usescollapsable={false}. If the screen does not use aScrollVieworFlatList, setdisableTransparentOnScrollEdgetotruein theNativeTabs.Triggeroptions, to make the tab bar opaque.Scroll to top not working: Ensure
disableScrollToTopis not set on the active tab's Trigger andScrollViewis the first child of the screen component.Header buttons flicker when navigating between tabs: Make sure the app is wrapped in a
ThemeProvider
import {
ThemeProvider,
DarkTheme,
DefaultTheme,
} from "expo-router/react-navigation";
import { useColorScheme } from "react-native";
import { Stack } from "expo-router";
export default function Layout() {
const colorScheme = useColorScheme();
return (
<ThemeProvider value={colorScheme === "dark" ? DarkTheme : DefaultTheme}>
<Stack />
</ThemeProvider>
);
}If the app only uses a light or dark theme, you can directly pass DarkTheme or DefaultTheme to ThemeProvider without checking the color scheme.
import { ThemeProvider, DarkTheme } from "expo-router/react-navigation";
import { Stack } from "expo-router";
export default function Layout() {
return (
<ThemeProvider value={DarkTheme}>
<Stack />
</ThemeProvider>
);
}Requirements and compatibility
Works with: Claude Code, Claude apps, OpenAI Codex, GitHub Copilot, Cursor, Gemini CLI, Kiro, OpenCode, Goose, Amp, Roo Code, JetBrains Junie, Factory Droid, Devin (ex-Windsurf), Mistral Vibe, OpenHands, Letta
“Works with” means the tool reads the open Agent Skills format; a skill written for one tool may use features others lack.
Needs: Needs Node.js
Security
What our scanner noticed· Low risk
- Instructions that use the network
A static scan is not a guarantee. Skills are third-party content: review a skill before you let an agent use it.
Attribution and licence
- Published by
- Expo
- Publisher
- Expo (Official)
- Source
- expo/skills · plugins/expo/skills/expo-router · commit a9e5bd3
- Licence
- MIT License (redistribution allowed)
- LICENSE (added by AutoGineer from plugins/expo/LICENSE)
- ATTRIBUTION.md (written by AutoGineer)
- Synced
AutoGineer is not affiliated with or endorsed by Expo or Expo.
Download ZIP
The ZIP holds the skill’s files, its licence files and an ATTRIBUTION.md. Download expo-router.zip