Mobile Apps with React Native and Expo · Foundations: Screens, Styles, Lists and Navigation

Navigation with Expo Router

Expo Router turns files into screens: app/index.tsx is the home screen, app/(tabs) holds tab screens, and app/contact/[id].tsx is a details screen with a parameter. Deep links work automatically.

Create a free account to save this page, take notes and track your progress. Sign in

What you'll build

An app with two tabs (Contacts and Settings), a contact details screen opened from the list, and a deep link myapp://contact/42 that opens the details screen directly from a notification or a web page.

The idea

Expo Router uses the file system as the navigation map, much like Next.js on the web:

app/
  _layout.tsx            root layout: a Stack (screens slide in on top of each other)
  (tabs)/
    _layout.tsx          a Tabs layout for the screens in this folder
    index.tsx            /           -> Contacts tab
    settings.tsx         /settings   -> Settings tab
  contact/
    [id].tsx             /contact/42 -> details screen, id = "42"
  +not-found.tsx         unknown routes
  • A Stack pushes screens on top with a back button; Tabs switch between sibling screens.
  • Folders in parentheses like (tabs) group screens without adding to the URL.
  • [id].tsx is a dynamic route; read the value with useLocalSearchParams().
  • Navigate with <Link href="/contact/42"> or router.push("/contact/42").
  • Every screen has a URL, so deep links (myapp://contact/42) and links on the web work with no extra code. Set the scheme in app.json ("scheme": "myapp").

The code

// app/_layout.tsx
import { Stack } from "expo-router";
export default function RootLayout() {
  return (
    <Stack>
      <Stack.Screen name="(tabs)" options={{ headerShown: false }} />
      <Stack.Screen name="contact/[id]" options={{ title: "Contact" }} />
    </Stack>
  );
}

// app/(tabs)/_layout.tsx
import { Tabs } from "expo-router";
export default function TabsLayout() {
  return (
    <Tabs>
      <Tabs.Screen name="index" options={{ title: "Contacts" }} />
      <Tabs.Screen name="settings" options={{ title: "Settings" }} />
    </Tabs>
  );
}

// app/(tabs)/index.tsx
import { Link } from "expo-router";
import { Text, View } from "react-native";
export default function ContactsTab() {
  return (
    <View style={{ padding: 16, gap: 12 }}>
      <Link href="/contact/42"><Text>Open Ana</Text></Link>
      <Link href={{ pathname: "/contact/[id]", params: { id: "7" } }}><Text>Open Ben</Text></Link>
    </View>
  );
}

// app/contact/[id].tsx
import { useLocalSearchParams, router } from "expo-router";
import { Button, Text, View } from "react-native";
export default function ContactDetails() {
  const { id } = useLocalSearchParams<{ id: string }>();
  return (
    <View style={{ padding: 16, gap: 12 }}>
      <Text>Contact #{id}</Text>
      <Button title="Back" onPress={() => router.back()} />
    </View>
  );
}

Try the logic

Under the hood, a router matches a URL against route patterns and pulls out parameters. Writing a tiny matcher shows exactly what [id] and (tabs) mean, and why deep links "just work".

Match deep links to file-based routesTry it yourself
const files = ["(tabs)/index", "(tabs)/settings", "contact/[id]", "contact/[id]/edit", "post/[...slug]"];

type Match = { file: string; params: Record<string, string> };

function toPattern(file: string): string[] {
  return file
    .split("/")
    .filter((part) => !/^\(.*\)$/.test(part))      // (groups) don't appear in the URL
    .map((part) => (part === "index" ? "" : part))
    .filter(Boolean);
}

function match(url: string): Match | null {
  const path = new URL(url).pathname.replace(/^\/+/, "");
  const host = new URL(url).host;                      // myapp://contact/42 puts "contact" in the host
  const parts = [host, ...path.split("/")].filter(Boolean);
  for (const file of files) {
    const pattern = toPattern(file);
    const params: Record<string, string> = {};
    let ok = true;
    for (let i = 0; i < pattern.length && ok; i++) {
      const seg = pattern[i];
      if (seg.startsWith("[...")) {                    // catch-all: the rest of the path
        params[seg.slice(4, -1)] = parts.slice(i).join("/");
        return { file, params };
      }
      if (seg.startsWith("[")) params[seg.slice(1, -1)] = parts[i] ?? "";
      else ok = seg === parts[i];
    }
    if (ok && pattern.length === parts.length) return { file, params };
  }
  return null;
}

for (const url of ["myapp://", "myapp://settings", "myapp://contact/42", "myapp://contact/42/edit",
                   "myapp://post/2026/10/launch", "myapp://nope/1"]) {
  const m = match(url);
  console.log(url.padEnd(28), "->", m ? `${m.file} ${JSON.stringify(m.params)}` : "+not-found");
}

Output

myapp://                     -> (tabs)/index {}
myapp://settings             -> (tabs)/settings {}
myapp://contact/42           -> contact/[id] {"id":"42"}
myapp://contact/42/edit      -> contact/[id]/edit {"id":"42"}
myapp://post/2026/10/launch  -> post/[...slug] {"slug":"2026/10/launch"}
myapp://nope/1               -> +not-found

Common mistakes

  • Treating params as numbers. URL params are always strings; convert with Number(id) and validate.
  • Putting secrets or whole objects in params. Pass an ID and load the data on the screen.
  • Forgetting the scheme in app.json, so deep links don't open the app.
  • Deep link screens that assume earlier screens ran. A deep-linked details screen must load its own data and handle "not found".
  • Nesting stacks inside tabs inside stacks without a plan. Sketch the navigation tree first.

Key points

  • Files in app/ are routes; _layout.tsx defines Stack or Tabs.
  • (group) folders organise without changing URLs; [id] captures a parameter.
  • Link, router.push, router.back; params via useLocalSearchParams.
  • Every screen has a URL, so deep links come for free (set scheme).

Further reading: Expo Router introduction, Linking and deep links.

Practice questions

In Expo Router, what do `(tabs)` and `[id]` mean in file names?

Answer

A folder in parentheses is a route group: it organises screens (for example under a Tabs layout) without adding a segment to the URL. A file named [id].tsx is a dynamic route; the matching URL segment is available via useLocalSearchParams().

Why must a deep-linked screen load its own data?

Answer

A deep link can open the screen directly (from a notification or link) without the user passing through the list screen, so data from earlier screens may not exist. The screen should load by ID and handle loading and not-found states itself.

Open in the app to mark lessons complete, take notes and track your progress.

Was this page helpful?