Navigation with Expo Router
By the end of this lesson you will be able to build an app with several screens and move between them. You will create a stack of screens, link them, pass an ID to a details screen, and control the back button.
Expo Router is the navigation system in new Expo projects. It is built on top of React Navigation, the long-standing navigation library for React Native, and adds file-based routing: your files decide your screens.
How mobile navigation feels
On the web, every page has a URL and the browser keeps a history. On a phone, screens slide in from the side and pile up, like plates stacked in a canteen. Opening a screen puts a new plate on top (a push). Pressing back takes the top plate off (a pop). This pile is called a stack.
Practise the idea first. This browser demo keeps a stack in state with a reducer. It is plain React, so it runs here.
import { useReducer } from "react";
import { createRoot } from "react-dom/client";
function stackReducer(stack, action) {
switch (action.type) {
case "push":
return [...stack, action.screen];
case "back":
return stack.length > 1 ? stack.slice(0, -1) : stack;
case "replace":
return [...stack.slice(0, -1), action.screen];
default:
return stack;
}
}
function FakePhone() {
const [stack, dispatch] = useReducer(stackReducer, ["Home"]);
const current = stack[stack.length - 1];
return (
<div style={{ fontFamily: "sans-serif" }}>
<p>Stack: {stack.join(" > ")}</p>
<h3>Now showing: {current}</h3>
<button onClick={() => dispatch({ type: "push", screen: "Shop 7" })}>Open Shop 7</button>{" "}
<button onClick={() => dispatch({ type: "push", screen: "Cart" })}>Open Cart</button>{" "}
<button onClick={() => dispatch({ type: "replace", screen: "Payment done" })}>Replace with Payment done</button>{" "}
<button onClick={() => dispatch({ type: "back" })}>Back</button>
</div>
);
}
createRoot(document.getElementById("root")).render(<FakePhone />);Press "Open Shop 7", then "Open Cart". The stack reads "Home > Shop 7 > Cart". Press "Replace with Payment done" and Cart is swapped out, so Back now goes straight to Shop 7. Expo Router does this bookkeeping for you, but the idea is exactly the same.
Step 1: a root layout with a Stack
Start from a blank project (npm run reset-project). Open src/app/_layout.jsx (or .tsx) and render a Stack. Every screen in src/app now becomes a page in this stack.
// src/app/_layout.jsx
import { Stack } from "expo-router";
export default function RootLayout() {
return (
<Stack screenOptions={{ headerStyle: { backgroundColor: "#087ea4" }, headerTintColor: "white" }}>
<Stack.Screen name="index" options={{ title: "Rohtak Bazaar" }} />
<Stack.Screen name="about" options={{ title: "About us" }} />
</Stack>
);
}What you should see: a blue header bar with the white title "Rohtak Bazaar" above your home screen. screenOptions applies to every screen, and each Stack.Screen sets options for one file. The name is the file name without the extension.
Step 2: link to another screen
Create src/app/about.jsx and link to it from the home screen with Link.
// src/app/index.jsx
import { Link } from "expo-router";
import { View, Text, Pressable } from "react-native";
export default function Home() {
return (
<View style={{ flex: 1, padding: 24, gap: 16 }}>
<Text style={{ fontSize: 18 }}>Fresh fruit and sweets since 1982.</Text>
<Link href="/about">Read our story</Link>
<Link href="/about" asChild>
<Pressable style={{ backgroundColor: "#087ea4", padding: 12, borderRadius: 8 }}>
<Text style={{ color: "white", textAlign: "center" }}>About us (button)</Text>
</Pressable>
</Link>
</View>
);
}What you should see: a text link and a blue button. Tapping either slides the About screen in from the right, with a back arrow in its header. Tapping back, or swiping from the left edge on iPhone, returns home.
By default Link renders a Text. With asChild, it passes its press handling to your own child, such as a styled Pressable.
Step 3: dynamic routes and params
A shop app has many shops but one shop screen. Name the file with square brackets: src/app/shops/[id].jsx. The part in brackets becomes a param, a value read from the URL.
// src/app/shops/[id].jsx
import { Stack, useLocalSearchParams } from "expo-router";
import { View, Text } from "react-native";
const shops = { "7": { name: "Gupta Sweets", city: "Rohtak" }, "9": { name: "Kerala Spices", city: "Kochi" } };
export default function ShopScreen() {
const { id, from } = useLocalSearchParams();
const shop = shops[id];
if (!shop) {
return <Text style={{ padding: 24 }}>Shop {id} was not found.</Text>;
}
return (
<View style={{ padding: 24 }}>
<Stack.Screen options={{ title: shop.name }} />
<Text style={{ fontSize: 20 }}>{shop.name}, {shop.city}</Text>
{from ? <Text>You came from: {from}</Text> : null}
</View>
);
}Link to it with a string or an object:
<Link href="/shops/7">Gupta Sweets</Link>
<Link href={{ pathname: "/shops/[id]", params: { id: "9", from: "offers" } }}>Kerala Spices</Link>What you should see: tapping "Gupta Sweets" opens a screen whose header title is "Gupta Sweets" and whose body says "Gupta Sweets, Rohtak". Tapping "Kerala Spices" also shows "You came from: offers", because extra params that do not match a bracket become query parameters (/shops/9?from=offers).
Rendering <Stack.Screen options={...} /> inside a screen sets that screen's options from its own data, which is how the header shows the shop's name.
Step 4: navigate from code with router
Sometimes you navigate after something happens, such as a successful payment. Import router (or call useRouter()) and use its methods:
import { router } from "expo-router";
async function placeOrder(cart) {
const orderId = await saveOrder(cart); // your own function
router.replace({ pathname: "/orders/[id]", params: { id: String(orderId) } });
}
// other methods:
// router.push("/cart") add a screen on top of the stack
// router.navigate("/cart") go to /cart, reusing it if it is already in the stack
// router.back() go back one screen
// router.dismissAll() close every screen above the first one in this stackUse push for normal forward movement. Use replace when the user should not come back with the back button, such as after login or payment. Nobody wants to press back and land on the payment form again.
Typed routes and the not-found screen
New projects turn on typed routes in app.json. In .tsx files your editor then warns you about links to screens that do not exist, such as /abuot. You can also add src/app/+not-found.jsx to show a friendly screen for unknown links.
Common mistakes
- Placing a screen outside
src/app. Only files insidesrc/appare routes. - Forgetting that params are strings.
id === 7is always false; useNumber(id) === 7. - Using
pushafter login. The back button then returns to the login form. Usereplace. - A
Stack.Screenname that does not match a file. The options are ignored. Check the spelling ofname. - Wrapping a
PressableinLinkwithoutasChild. You get a text element around your button; addasChild.
Key points
- Expo Router is file-based: files in
src/appare screens, and paths are URLs. - A
Stackin_layoutshows screens as a pile with a header and back button. Linkwithhrefnavigates;asChildlets your own component be the link.[id].jsxmakes a dynamic route; read values withuseLocalSearchParams().router.push,replace,navigateandbacknavigate from code.- Render
<Stack.Screen options={...} />in a screen to set its own title.
Exercise
Build a three-screen app for a college canteen. The home screen lists three dishes with Links. Each link opens src/app/dishes/[id].jsx, which shows the dish name as the header title and its price in the body. Add an "Order" button on the dish screen that uses router.replace to go to src/app/thanks.jsx, which says "Order placed for DISH". Pass the dish name as a param.
Show solution
// src/app/dishes/[id].jsx
import { Stack, router, useLocalSearchParams } from "expo-router";
import { View, Text, Pressable } from "react-native";
const dishes = { "1": { name: "Chole bhature", price: 70 }, "2": { name: "Masala dosa", price: 60 }, "3": { name: "Veg thali", price: 120 } };
export default function Dish() {
const { id } = useLocalSearchParams();
const dish = dishes[id];
if (!dish) return <Text>Dish not found</Text>;
return (
<View style={{ padding: 24, gap: 16 }}>
<Stack.Screen options={{ title: dish.name }} />
<Text style={{ fontSize: 20 }}>₹{dish.price}</Text>
<Pressable onPress={() => router.replace({ pathname: "/thanks", params: { dish: dish.name } })}>
<Text>Order</Text>
</Pressable>
</View>
);
}
// src/app/thanks.jsx
import { useLocalSearchParams } from "expo-router";
import { Text } from "react-native";
export default function Thanks() {
const { dish } = useLocalSearchParams();
return <Text style={{ padding: 24 }}>Order placed for {dish}</Text>;
}On the home screen, links such as <Link href="/dishes/1">Chole bhature</Link> open the dish screen. Because the Order button uses replace, pressing back on the thanks screen goes home, not to the dish screen.
Quick Quiz
1. Which file creates a screen at the URL /shops/42?
Square brackets make a dynamic segment, so [id].jsx matches any shop ID.
2. How do you read the id param inside a screen?
useLocalSearchParams() returns the route's dynamic and query params.
3. After a successful login, which method should send the user to the home screen?
replace swaps the login screen out, so back does not return to it.
4. What type is a param such as id when it arrives?
URL params are always strings; convert them when you need numbers.
Learn React Native with a mentor
Free lessons get you started. Our Mobile App Development training adds real projects and code reviews from working developers.
- Project-based learning
- Mentor code reviews
- Free counselling call
