Mobile App Development
Hands-onBeginner 35 min· Foundations · 4 of 12

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

FileRouteRole
app/_layout.jsnoneRoot layout: a Stack around everything
app/(tabs)/_layout.jsnoneTab bar layout
app/(tabs)/index.js/Menu screen (first tab)
app/(tabs)/cart.js/cartCart screen (second tab)
app/item/[id].js/item/espressoDetail 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

  1. 1Create the folder app/(tabs).
  2. 2Move app/index.js (your menu screen) into it: app/(tabs)/index.js.
  3. 3In that moved file, fix the two imports, since it is now one folder deeper: ../../components/MenuItem and ../../data/menu.
  4. 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

app/_layout.js
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

app/(tabs)/_layout.js
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.

app/(tabs)/cart.js
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.

components/MenuItem.js
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.

app/item/[id].js
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.

What you should see
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

CallWhat 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

  1. Leaving old _layout.tsx or index.tsx files from the starter next to your .js versions.
  2. A Tabs.Screen name that does not match a file in the folder. A typo gives a warning and a missing tab.
  3. Forgetting headerShown: false on the group, which shows two headers stacked.
  4. 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

  1. Expo Router maps files in the app folder to screens; [id] makes a dynamic route.
  2. _layout files decide whether sibling screens form a Stack or Tabs.
  3. Folders in parentheses group files without changing the URL.
  4. Navigate with Link or router.push, replace and back; read params with useLocalSearchParams.
  5. 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.

Start free