Navigation with Expo Router
Add tabs, a stack and a dynamic detail screen to the Brew & Bean app using Expo Router: file-based routes, layouts, route params, Link and router.push, and how deep links work.
A real app has more than one screen. In this tutorial you add navigation to the Brew & Bean app: two tabs (Menu and Cart), and a detail screen that opens when you tap a menu item, with the item's id passed in the route. You will use Expo Router, which turns files in the app folder into screens.
Prerequisites: the previous lesson (data/menu.js, components/MenuItem.js and the menu screen). Your project from the Expo starter already includes Expo Router.
How Expo Router works
In Expo Router, the file system is the navigation map. If you know Next.js, this will feel familiar. A file at app/about.js becomes the route /about. A file called index.js is the route for its folder. Square brackets make a dynamic part: app/item/[id].js matches /item/espresso, /item/croissant and so on.
A file named _layout.js does not become a screen. It defines how its sibling screens are arranged: as a Stack (screens pushed one on top of another, with a back button) or as Tabs (a bar at the bottom).
The route map we will build
| File | Route | Role |
|---|---|---|
| app/_layout.js | none | Root layout: a Stack around everything |
| app/(tabs)/_layout.js | none | Tab bar layout |
| app/(tabs)/index.js | / | Menu screen (first tab) |
| app/(tabs)/cart.js | /cart | Cart screen (second tab) |
| app/item/[id].js | /item/espresso | Detail screen for one item |
The folder name (tabs) has brackets with parentheses. Parentheses make a group: the folder organises files and shares a layout, but its name does not appear in the URL. That is why app/(tabs)/index.js is still /.
Step 1: Reorganise the files
- 1Create the folder
app/(tabs). - 2Move
app/index.js(your menu screen) into it:app/(tabs)/index.js. - 3In that moved file, fix the two imports, since it is now one folder deeper:
../../components/MenuItemand../../data/menu. - 4Delete
app/_layout.tsx(from the starter). We create our own in JavaScript next. Leaving both would create two layouts with the same route.
Step 2: The root layout
import { Stack } from "expo-router";
export default function RootLayout() {
return (
<Stack
screenOptions={{
headerStyle: { backgroundColor: "#4a2c17" },
headerTintColor: "white",
}}
>
<Stack.Screen name="(tabs)" options={{ headerShown: false }} />
<Stack.Screen name="item/[id]" options={{ title: "Item" }} />
</Stack>
);
}The tab group shows its own header, so the stack hides its header for it. The detail screen gets a brown header with an automatic back button.
Step 3: The tab bar
import { Tabs } from "expo-router";
import { Ionicons } from "@expo/vector-icons";
export default function TabsLayout() {
return (
<Tabs
screenOptions={{
tabBarActiveTintColor: "#4a2c17",
headerStyle: { backgroundColor: "#4a2c17" },
headerTintColor: "white",
}}
>
<Tabs.Screen
name="index"
options={{
title: "Menu",
tabBarIcon: ({ color, size }) => <Ionicons name="cafe-outline" size={size} color={color} />,
}}
/>
<Tabs.Screen
name="cart"
options={{
title: "Cart",
tabBarIcon: ({ color, size }) => <Ionicons name="cart-outline" size={size} color={color} />,
}}
/>
</Tabs>
);
}Each Tabs.Screen name matches a file in the same folder. @expo/vector-icons ships with Expo, so no install is needed.
import { View, Text, StyleSheet } from "react-native";
export default function Cart() {
return (
<View style={styles.container}>
<Text style={styles.text}>Your cart is empty.</Text>
</View>
);
}
const styles = StyleSheet.create({
container: { flex: 1, alignItems: "center", justifyContent: "center", backgroundColor: "#fff8f0" },
text: { fontSize: 18, color: "#6b4f3a" },
});A placeholder for now. A later lesson stores the cart on the device.
Tabs now has its own header, so in app/(tabs)/index.js you can remove the "Brew & Bean" title Text and reduce paddingTop to 16. The header shows the screen title ("Menu") instead.
Step 4: Open a detail screen
To navigate, use the Link component, or call router.push(...) in code. Update components/MenuItem.js so that the whole row is tappable and goes to /item/<id>. asChild tells Link to pass its behaviour to our Pressable instead of rendering its own text wrapper.
import { View, Text, Pressable, StyleSheet } from "react-native";
import { Link } from "expo-router";
export default function MenuItem({ item }) {
return (
<Link href={`/item/${item.id}`} asChild>
<Pressable style={styles.row}>
<View style={styles.info}>
<Text style={styles.name}>{item.name}</Text>
<Text style={styles.category}>{item.category}</Text>
</View>
<Text style={styles.price}>₹{item.price}</Text>
</Pressable>
</Link>
);
}
const styles = StyleSheet.create({
row: {
flexDirection: "row",
alignItems: "center",
justifyContent: "space-between",
backgroundColor: "white",
padding: 16,
borderRadius: 12,
marginBottom: 10,
},
info: { flex: 1 },
name: { fontSize: 18, fontWeight: "600", color: "#2b1a0e" },
category: { fontSize: 14, color: "#8a6f5a", textTransform: "capitalize" },
price: { fontSize: 18, fontWeight: "bold", color: "#4a2c17" },
});Same row as before, wrapped in a Link that points to the dynamic route.
Step 5: Read the route parameter
The detail screen reads id from the route with the useLocalSearchParams hook. It then finds the item in the menu data. Because the id is part of the file name [id].js, the key is id.
import { View, Text, Pressable, StyleSheet } from "react-native";
import { Stack, useLocalSearchParams, useRouter } from "expo-router";
import { MENU } from "../../data/menu";
export default function ItemDetail() {
const { id } = useLocalSearchParams();
const router = useRouter();
const item = MENU.find((m) => m.id === id);
if (!item) {
return (
<View style={styles.center}>
<Text style={styles.name}>Item not found</Text>
<Pressable style={styles.button} onPress={() => router.back()}>
<Text style={styles.buttonText}>Go back</Text>
</Pressable>
</View>
);
}
return (
<View style={styles.container}>
<Stack.Screen options={{ title: item.name }} />
<Text style={styles.name}>{item.name}</Text>
<Text style={styles.category}>{item.category}</Text>
<Text style={styles.price}>₹{item.price}</Text>
<Pressable style={styles.button} onPress={() => router.back()}>
<Text style={styles.buttonText}>Back to menu</Text>
</Pressable>
</View>
);
}
const styles = StyleSheet.create({
container: { flex: 1, padding: 24, backgroundColor: "#fff8f0" },
center: { flex: 1, alignItems: "center", justifyContent: "center", backgroundColor: "#fff8f0" },
name: { fontSize: 28, fontWeight: "bold", color: "#2b1a0e" },
category: { fontSize: 16, color: "#8a6f5a", textTransform: "capitalize", marginTop: 4 },
price: { fontSize: 24, color: "#4a2c17", marginTop: 16 },
button: { backgroundColor: "#4a2c17", padding: 14, borderRadius: 10, marginTop: 32, alignItems: "center" },
buttonText: { color: "white", fontSize: 16 },
});Stack.Screen inside a screen sets that screen's header title at runtime, so the title becomes the item name.
The app opens on the Menu tab with a brown header. The Cart tab is next to it. Tapping "Cold Brew" slides in a screen titled "Cold Brew" with its category and ₹220. The header has a back arrow, and so does the Android back button.Moving around in code
Navigating with router
| Call | What it does |
|---|---|
| router.push("/cart") | Go to a screen and keep the current one underneath |
| router.replace("/cart") | Go to a screen and remove the current one from history (for example after login) |
| router.back() | Go back one screen |
| router.push({ pathname: "/item/[id]", params: { id: "espresso" } }) | The same as pushing a link, with params as an object |
Params are always strings in the URL. If you pass a number, such as an id of 5, convert it back with Number(id) before you compare it with a number.
Pass ids, not whole objects
Do not pass large objects through route params. Pass an id and load the data on the screen, as we did here. Params appear in the URL, which also means deep links and sharing work for free.
Deep links and the not-found screen
Because every screen has a path, other apps and web links can open a specific screen. In app.json, the scheme field (for example "scheme": "brewbean") lets brewbean://item/espresso open the detail screen once the app is built. Expo Router also shows a not-found screen for unknown routes; you can customise it with a file named +not-found.js in the app folder.
Common mistakes
Watch out for
- Leaving old
_layout.tsxorindex.tsxfiles from the starter next to your.jsversions. - A
Tabs.Screenname that does not match a file in the folder. A typo gives a warning and a missing tab. - Forgetting
headerShown: falseon the group, which shows two headers stacked. - Relative imports that were not updated after moving a file into
(tabs).
Try it: Add an About tab and a cart button
Add a third tab called "About" with the shop address and opening hours (8 am to 10 pm). On the item detail screen, add an "Add to cart" button that calls router.push("/cart"). Then try opening /item/unknown through a Link to see your not-found message.
Key takeaways
- Expo Router maps files in the app folder to screens; [id] makes a dynamic route.
- _layout files decide whether sibling screens form a Stack or Tabs.
- Folders in parentheses group files without changing the URL.
- Navigate with Link or router.push, replace and back; read params with useLocalSearchParams.
- Pass ids in params and load the data on the destination screen.
Check yourself
Finishing the quiz marks the lesson as done.
1. What route does the file app/item/[id].js match?
2. What does a folder named (tabs) do?
3. How does a detail screen read the id passed in the route?
4. After a successful login you want to open the home screen so that the Back button does not return to login. Which call fits?
5. What is a better design for passing a menu item to the detail screen?
Saved on this device. Create a free account to keep it on every device.
Practise this with Sam
A real mock interview, out loud, with an honest scorecard. Free to start.