In Expo Router every file in src/app is a screen with its own URL: notes/[id].tsx serves /notes/1, /notes/2 and so on, and _layout.tsx decides how a folder’s screens relate — as a stack (Stack) or tabs (Tabs). You navigate with <Link href> or the router object (push, navigate, replace, back), and read a parameter with the useLocalSearchParams hook. Because every screen has a URL, deep links like notesapp://notes/1 work without extra setup.
After this you will be able to
- Describe routes as files in
src/app:index, a dynamic[id], a(tabs)group and_layout - Set up a root
StackwithTabsand a modal screen and move between them withLinkandrouter - Pass a param to a dynamic route, read it with
useLocalSearchParamsand go back safely after a deep link - Check navigation without a phone: typed routes in
tscand arenderRoutertest
Know beforehand
Verified with
Expo SDK 57 (expo 57.0.27)Expo Router 57.0.25React Native 0.86.3React 19.2.3TypeScript 6.0
Checked against docs: October 11, 2026
Contents
Routes are files in src/app
Expo Router is a file-based router for React Native and the web. The documentation states the rule simply: all of an app’s routes are defined by the files and folders inside src/app, every screen has a URL path that matches the file’s location, and index.tsx opens first. Code that is not a screen — components, hooks, data — stays outside src/app, otherwise Expo Router will try to turn it into a route.
The lesson’s practice is a Notes app: two tabs (the list and About), a note screen with a parameter in the URL, and a modal for creating a note. The project was created from the default template, as in the previous lesson, with the examples reset and icons added.
npx create-expo-app@latest notes-app --yes
cd notes-app
npm run reset-project
npx expo install @expo/vector-iconssrc/app/_layout.tsx
- URL
- —
- Role
- Root stack
src/app/(tabs)/_layout.tsx
- URL
- —
- Role
- Tabs
src/app/(tabs)/index.tsx
- URL
/- Role
- Notes list
src/app/(tabs)/about.tsx
- URL
/about- Role
- About
src/app/notes/[id].tsx
- URL
/notes/1,/notes/2, …- Role
- A note
src/app/new.tsx
- URL
/new- Role
- Modal
src/data/notes.tsx
- URL
- —
- Role
- Data, not a route
index.tsx
- Meaning
- The default screen for a folder
- Example
(tabs)/index.tsx→/
[id].tsx
- Meaning
- A dynamic segment: matches any value
- Example
notes/[id].tsx→/notes/42
(tabs)/
- Meaning
- A group: bundles screens but stays out of the URL
- Example
(tabs)/about.tsx→/about
_layout.tsx
- Meaning
- The folder’s navigator: how its screens relate
- Example
Stack,Tabs
+not-found.tsx
- Meaning
- The screen for URLs that match nothing
- Example
- Expo Router shows its own if the file is missing
_layout: stack and tabs
The root _layout.tsx replaces App.tsx: it renders before every screen, so providers, fonts and the theme go here. We keep notes in a React context so the list and the modal work with the same data. Persisting between launches is the next lesson’s topic; here the data lives in memory.
import { createContext, use, useState, type ReactNode } from 'react';
export type Note = {
id: string;
title: string;
body: string;
};
type NotesContextValue = {
notes: Note[];
addNote: (title: string, body: string) => Note;
};
const NotesContext = createContext<NotesContextValue | null>(null);
const initialNotes: Note[] = [
{ id: '1', title: 'Call the supplier', body: 'Confirm the delivery date for Friday.' },
{ id: '2', title: 'Team meeting', body: 'Prepare the agenda and the weekly report.' },
];
export function NotesProvider({ children }: { children: ReactNode }) {
const [notes, setNotes] = useState(initialNotes);
function addNote(title: string, body: string) {
const note = { id: String(Date.now()), title, body };
setNotes((current) => [note, ...current]);
return note;
}
return <NotesContext value={{ notes, addNote }}>{children}</NotesContext>;
}
export function useNotes() {
const value = use(NotesContext);
if (!value) {
throw new Error('useNotes must be used inside NotesProvider');
}
return value;
}import { Stack } from 'expo-router';
import { NotesProvider } from '@/data/notes';
export default function RootLayout() {
return (
<NotesProvider>
<Stack>
<Stack.Screen name="(tabs)" options={{ headerShown: false }} />
<Stack.Screen name="notes/[id]" options={{ title: 'Note' }} />
<Stack.Screen name="new" options={{ presentation: 'modal', title: 'New note' }} />
</Stack>
</NotesProvider>
);
}Stack is a stack of screens: a new screen goes on top, and the back button or gesture removes it. Stack.Screen does not create routes here (files do) — it configures them: the title, header visibility, presentation. The (tabs) group is one screen of the root stack with its own navigator inside.
import Ionicons from '@expo/vector-icons/Ionicons';
import { Link, Tabs } from 'expo-router';
import { Pressable, StyleSheet, Text } from 'react-native';
export default function TabLayout() {
return (
<Tabs screenOptions={{ tabBarActiveTintColor: '#2563eb' }}>
<Tabs.Screen
name="index"
options={{
title: 'Notes',
tabBarIcon: ({ color, size }) => (
<Ionicons name="document-text-outline" size={size} color={color} />
),
headerRight: () => (
<Link href="/new" asChild>
<Pressable accessibilityRole="button" style={styles.headerButton}>
<Text style={styles.headerButtonText}>New</Text>
</Pressable>
</Link>
),
}}
/>
<Tabs.Screen
name="about"
options={{
title: 'About',
tabBarIcon: ({ color, size }) => (
<Ionicons name="information-circle-outline" size={size} color={color} />
),
}}
/>
</Tabs>
);
}
const styles = StyleSheet.create({
headerButton: {
marginRight: 16,
paddingHorizontal: 12,
paddingVertical: 6,
borderRadius: 8,
backgroundColor: '#2563eb',
},
headerButtonText: {
color: '#ffffff',
fontWeight: '600',
},
});Tabs from expo-router are JavaScript tabs: they look the same on every platform and are configured through screenOptions (for all tabs) and options (for one). The icon receives color and size from the navigator, so the active tab is highlighted automatically. To hide a route from the tab bar, give it href: null.
Link and router: moving between screens
Declarative navigation is the Link component. By default it wraps its content in Text; with asChild it hands the press to its child, for example a Pressable with its own styles. The target can be a string (/notes/1) or an object with pathname and params — the second is handier when the value comes from data.
import { Link } from 'expo-router';
import { FlatList, Pressable, StyleSheet, Text } from 'react-native';
import { useNotes } from '@/data/notes';
export default function NotesScreen() {
const { notes } = useNotes();
return (
<FlatList
data={notes}
keyExtractor={(note) => note.id}
contentContainerStyle={styles.list}
ListEmptyComponent={<Text style={styles.empty}>No notes yet</Text>}
renderItem={({ item }) => (
<Link href={{ pathname: '/notes/[id]', params: { id: item.id } }} asChild>
<Pressable style={styles.row}>
<Text style={styles.title}>{item.title}</Text>
<Text style={styles.preview} numberOfLines={1}>
{item.body}
</Text>
</Pressable>
</Link>
)}
/>
);
}
const styles = StyleSheet.create({
list: {
padding: 16,
gap: 12,
},
row: {
padding: 16,
borderRadius: 12,
backgroundColor: '#ffffff',
gap: 4,
},
title: {
fontSize: 17,
fontWeight: '600',
color: '#18181b',
},
preview: {
fontSize: 15,
color: '#71717a',
},
empty: {
textAlign: 'center',
color: '#71717a',
},
});import { Link } from 'expo-router';
import { StyleSheet, Text, View } from 'react-native';
export default function AboutScreen() {
return (
<View style={styles.container}>
<Text style={styles.text}>Notes is a demo of Expo Router: tabs, a stack and a modal.</Text>
<Link href="/notes/1" style={styles.link}>
Open the first note
</Link>
</View>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
padding: 16,
gap: 16,
},
text: {
fontSize: 16,
lineHeight: 24,
color: '#3f3f46',
},
link: {
fontSize: 16,
color: '#2563eb',
},
});navigate(href)
- What it does
- Opens a screen, or returns to it if it is already in the stack
- When to use it
- A regular transition — the default choice
push(href)
- What it does
- Always pushes a new screen, even if one already exists
- When to use it
- A chain of similar screens: note → related note
replace(href)
- What it does
- Swaps the current screen without a history entry
- When to use it
- After signing in, after saving a form
back() / canGoBack()
- What it does
- One step back / whether there is anywhere to go back to
- When to use it
- Your own back button
dismissTo(href)
- What it does
- Closes screens until the given one, otherwise replaces the current one
- When to use it
- Close a modal and return to the list
setParams(params)
- What it does
- Changes the current screen’s params without a new history entry
- When to use it
- Search and filters in the URL
In the web build Link becomes a real link: in our check the New button rendered in the browser as <a href="/new">. It can be opened in a new tab, and navigation happens without a page reload — the JavaScript state is kept.
A dynamic route and parameters
The notes/[id].tsx file serves any URL of the form /notes/<id>. The useLocalSearchParams hook returns the segment value — we type it with a generic. URL params are always strings (arrays of strings for [...rest] segments), so numbers and dates are parsed explicitly.
import { Stack, useLocalSearchParams, useRouter } from 'expo-router';
import { Pressable, StyleSheet, Text, View } from 'react-native';
import { useNotes } from '@/data/notes';
export default function NoteScreen() {
const { id } = useLocalSearchParams<{ id: string }>();
const { notes } = useNotes();
const router = useRouter();
const note = notes.find((item) => item.id === id);
function goBack() {
if (router.canGoBack()) {
router.back();
} else {
router.replace('/');
}
}
return (
<View style={styles.container}>
<Stack.Screen options={{ title: note?.title ?? 'Not found' }} />
<Text style={styles.body}>{note ? note.body : `Note ${id} does not exist.`}</Text>
<Pressable accessibilityRole="button" onPress={goBack} style={styles.button}>
<Text style={styles.buttonText}>Back to notes</Text>
</Pressable>
</View>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
padding: 16,
gap: 24,
},
body: {
fontSize: 17,
lineHeight: 26,
color: '#18181b',
},
button: {
alignSelf: 'flex-start',
paddingHorizontal: 16,
paddingVertical: 10,
borderRadius: 10,
backgroundColor: '#e4e4e7',
},
buttonText: {
fontSize: 16,
color: '#18181b',
},
});A title from the data
<Stack.Screen options={...} />inside a screen changes its header once the data is known — so the header shows the note’s title instead of the generic “Note”.An unknown id
A note may have been deleted while a link to it remains. The screen does not crash; it shows a clear message.
Going back without history
If the screen was opened from a deep link, there is no previous screen in the stack and
router.back()cannot go back. So we checkrouter.canGoBack()first and otherwise replace the screen with the list.Local and global params
useLocalSearchParamsupdates only when the URL belongs to this screen and does not re-render screens lower in the stack.useGlobalSearchParamsfollows any URL — the docs warn about extra re-renders.
A modal for creating a note
A modal screen is an ordinary route given presentation: 'modal' in the stack (done in the root _layout.tsx). On iOS it slides up from the bottom and is dismissed with a swipe down; on Android it is closed with the back button. On the web the documentation requires you to give the user a way to close it yourself — hence the Cancel button.
import { useRouter } from 'expo-router';
import { useState } from 'react';
import { Pressable, StyleSheet, Text, TextInput, View } from 'react-native';
import { useNotes } from '@/data/notes';
export default function NewNoteScreen() {
const router = useRouter();
const { addNote } = useNotes();
const [title, setTitle] = useState('');
const [body, setBody] = useState('');
const canSave = title.trim().length > 0;
function save() {
addNote(title.trim(), body.trim());
router.dismissTo('/');
}
return (
<View style={styles.container}>
<TextInput
placeholder="Title"
value={title}
onChangeText={setTitle}
style={styles.input}
autoFocus
/>
<TextInput
placeholder="Text"
value={body}
onChangeText={setBody}
style={[styles.input, styles.multiline]}
multiline
/>
<Pressable
accessibilityRole="button"
accessibilityState={{ disabled: !canSave }}
disabled={!canSave}
onPress={save}
style={[styles.button, !canSave && styles.buttonDisabled]}>
<Text style={styles.buttonText}>Save</Text>
</Pressable>
<Pressable accessibilityRole="button" onPress={() => router.dismissTo('/')}>
<Text style={styles.cancel}>Cancel</Text>
</Pressable>
</View>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
padding: 16,
gap: 12,
},
input: {
borderWidth: 1,
borderColor: '#d4d4d8',
borderRadius: 10,
padding: 12,
fontSize: 16,
},
multiline: {
minHeight: 120,
textAlignVertical: 'top',
},
button: {
alignItems: 'center',
padding: 14,
borderRadius: 10,
backgroundColor: '#2563eb',
},
buttonDisabled: {
opacity: 0.4,
},
buttonText: {
color: '#ffffff',
fontSize: 16,
fontWeight: '600',
},
cancel: {
textAlign: 'center',
padding: 8,
color: '#71717a',
},
});router.dismissTo('/') closes screens until it reaches the list. If the modal was opened directly at /new and the list is not in history, the method replaces the current screen with the list — no separate check is needed, unlike on the note screen.
Deep links
A deep link opens a specific screen of the app. The scheme is set by the scheme field in app.json — in our project the template already wrote "scheme": "notesapp". Expo Router enables deep links for every screen automatically: notesapp://notes/1 opens the note screen with id = "1", nothing else to describe. After adding or changing the scheme, the app has to be rebuilt.
# development build or emulator with the app installed
npx uri-scheme open notesapp://notes/1 --android
npx uri-scheme open notesapp://notes/1 --ios
# Expo Go: the in-app path comes after /--/
exp://127.0.0.1:8081/--/notes/1- Custom-scheme links do not work if the app is not installed. For that case there are universal links — regular
https://URLs that open the app or the website. Android calls them App Links, iOS Universal Links; both require a verification file on your domain. - A screen opened from a link ends up first in the stack. That is why the note screen’s back button checks
canGoBack(). - If the app is also published as a website with static export (
"output": "static"in the template), dynamic routes need agenerateStaticParamsfunction listing the known values — otherwise there will be no/notes/2page in the build.
Typed routes, tests and exercises
The template enables the experiments.typedRoutes flag: Expo generates types for every route, and TypeScript checks each href. The documentation calls the feature beta. The types appear when you run npx expo start, and without starting the server after npx expo customize tsconfig.json. We checked that errors really are caught: a typo in the path and a wrong param name.
<Link href="/note/1">Typo in the path</Link>
<Link href={{ pathname: '/notes/[id]', params: { noteId: '1' } }}>Wrong param</Link>$ npx tsc --noEmit
src/tmp/typed-check.tsx(6,13): error TS2322: Type '"/note/1"' is not assignable to type '"/" | RelativePathString | ExternalPathString | "/new" | …
src/tmp/typed-check.tsx(7,46): error TS2322: Type '{ noteId: string; }' is not assignable to type 'UnknownInputParams & { id: string | number; }'.
Property 'id' is missing in type '{ noteId: string; }' but required in type '{ id: string | number; }'.Navigation can be tested without a phone: expo-router/testing-library renders the app from the routes folder with a given initial URL. The test below walks “list → note → back”, opens a note from a deep link, creates a note through the modal and checks the fallback screen for an unknown id.
/// <reference types="jest" />
import { router } from 'expo-router';
import { act, fireEvent, renderRouter, screen } from 'expo-router/testing-library';
const appDir = './src/app';
describe('Notes navigation', () => {
test('list → note details → back', async () => {
const app = renderRouter(appDir, { initialUrl: '/' });
await app;
expect(app.getPathname()).toBe('/');
await fireEvent.press(screen.getByText('Call the supplier'));
expect(app.getPathname()).toBe('/notes/1');
expect(app.getSearchParams()).toEqual({ id: '1' });
expect(screen.getByText('Confirm the delivery date for Friday.')).toBeTruthy();
await fireEvent.press(screen.getByRole('button', { name: 'Back to notes' }));
expect(app.getPathname()).toBe('/');
});
test('deep link opens a note without history', async () => {
const app = renderRouter(appDir, { initialUrl: '/notes/2' });
await app;
expect(app.getPathname()).toBe('/notes/2');
expect(screen.getByText('Prepare the agenda and the weekly report.')).toBeTruthy();
await fireEvent.press(screen.getByRole('button', { name: 'Back to notes' }));
expect(app.getPathname()).toBe('/');
});
test('modal creates a note and closes', async () => {
const app = renderRouter(appDir, { initialUrl: '/' });
await app;
await act(() => router.push('/new'));
expect(app.getPathname()).toBe('/new');
await fireEvent.changeText(screen.getByPlaceholderText('Title'), 'Buy coffee beans');
await fireEvent.press(screen.getByRole('button', { name: 'Save' }));
expect(app.getPathname()).toBe('/');
expect(screen.getByText('Buy coffee beans')).toBeTruthy();
});
test('unknown id shows a fallback', async () => {
await renderRouter(appDir, { initialUrl: '/notes/999' });
expect(screen.getByText('Note 999 does not exist.')).toBeTruthy();
});
});$ npm test
PASS __tests__/navigation-test.tsx
Notes navigation
√ list → note details → back (352 ms)
√ deep link opens a note without history (29 ms)
√ modal creates a note and closes (70 ms)
√ unknown id shows a fallback (10 ms)
Tests: 4 passed, 4 totalWe walked the same scenarios in a browser on the web build: list, note, back, the modal with saving, the About tab and opening /notes/2 directly. The lesson was not run on a phone or emulator — that is your step.
- A component or hook sits in
src/appand shows up as an extra route — move it tosrc/componentsorsrc/data router.back()does nothing after a deep link — checkcanGoBack()or usedismissTo- A param is compared with a number and the note is not found — URL params are always strings
- A scheme link does not open the app — after adding
schemethe app must be rebuilt tscdoes not see route types — runnpx expo startornpx expo customize tsconfig.json
Next on the React Native path: loading data from an API and storing it on the device so notes survive a restart.
How this was verified5 · passed1 · not runchecked 11 October 2026
“Run” means the code was executed in a scratch project or container and the output in the article comes from that run. “Partial” means it was checked against stubs instead of external services. “Not run” means it needs a device, an account or a key; the steps were checked against the official documentation. The verification project files and run logs are kept in the InterLAB archive.