How to buildOctober 11, 20269 min read
React Native + ExpoStep 4 of 9

Navigation in Expo Router: stacks, tabs, parameters

In short

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.

TutorialLevel: IntermediateMobile NavigationPractice: ~1.5–3 h

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 Stack with Tabs and a modal screen and move between them with Link and router
  • Pass a param to a dynamic route, read it with useLocalSearchParams and go back safely after a deep link
  • Check navigation without a phone: typed routes in tsc and a renderRouter test

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
  1. 01Routes are files in src/app
  2. 02_layout: stack and tabs
  3. 03Link and router: moving between screens
  4. 04A dynamic route and parameters
  5. 05A modal for creating a note
  6. 06Deep links
  7. 07Typed routes, tests and exercises

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.

Terminal
npx create-expo-app@latest notes-app --yes
cd notes-app
npm run reset-project
npx expo install @expo/vector-icons
The Expo SDK 57 template already includes Expo Router; icons are installed with npx expo install

src/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
Project files and the URLs they get

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
Notation in file names

_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.

src/data/notes.tsx
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;
}
Data and context: the file is outside src/app, so it does not become a route
src/app/_layout.tsx
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>
  );
}
The root stack: a tab group without a header, the note screen and the modal

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.

src/app/(tabs)/_layout.tsx
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: labels, icons and a New button in the list’s header

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.

src/app/(tabs)/index.tsx
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',
  },
});
The notes list: each row is a Link to the note screen
src/app/(tabs)/about.tsx
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',
  },
});
The About tab: a Link with a string href

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
router methods from useRouter() or the router import

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.

src/app/notes/[id].tsx
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',
  },
});
The note screen: a param from the URL, a title from the data, a safe way back
  1. 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”.

  2. 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.

  3. 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 check router.canGoBack() first and otherwise replace the screen with the list.

  4. Local and global params

    useLocalSearchParams updates only when the URL belongs to this screen and does not re-render screens lower in the stack. useGlobalSearchParams follows 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.

src/app/new.tsx
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',
  },
});
The modal: a form, saving, and closing via dismissTo

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.

Terminal
# 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
Commands from the Expo documentation for testing a link on a device or emulator
  • 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 a generateStaticParams function listing the known values — otherwise there will be no /notes/2 page 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.

src/tmp/typed-check.tsx
<Link href="/note/1">Typo in the path</Link>
<Link href={{ pathname: '/notes/[id]', params: { noteId: '1' } }}>Wrong param</Link>
A fragment written with deliberate errors; deleted after the check
Terminal
$ 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; }'.
Real tsc output, the long list of allowed paths shortened

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.

__tests__/navigation-test.tsx
/// <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();
  });
});
Navigation test: Jest + jest-expo + React Native Testing Library 14 (installed as in the previous lesson)
Terminal
$ 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 total
Real test output

We 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/app and shows up as an extra route — move it to src/components or src/data
  • router.back() does nothing after a deep link — check canGoBack() or use dismissTo
  • 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 scheme the app must be rebuilt
  • tsc does not see route types — run npx expo start or npx 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.

Tell us about your task — we'll review your request and get back to you

Got a project?
We'll scope and estimate it — for free.

Tell us about the product or the problem. After reviewing the task we'll come back with an architecture, a timeline and a price for the first version.

  • Scope and risks estimated before we start
  • NDA — yours or ours
  • Work starts within a week

Please do not send passwords, access keys or source code through this form — we will agree a safe way to share access separately.

By clicking the button you agree to the processing of your personal data.