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.
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].tsxis a dynamic route; read the value withuseLocalSearchParams().- Navigate with
<Link href="/contact/42">orrouter.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 inapp.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".
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-foundCommon 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.tsxdefines Stack or Tabs. (group)folders organise without changing URLs;[id]captures a parameter.Link,router.push,router.back; params viauseLocalSearchParams.- 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.