Expo Router: Kompletny Przewodnik po Nawigacji Opartej na Plikach w React Native

Kompletny przewodnik po Expo Router — systemie nawigacji opartym na plikach dla React Native. File-based routing, protected routes, typed routes, API Routes, deep linking i Server Components w Expo SDK 53/54.

Expo Router: Kompletny Przewodnik po Nawigacji Opartej na Plikach w React Native

Jeśli kiedykolwiek budowałeś aplikację w React Native, to doskonale wiesz, że nawigacja potrafi być... frustrująca. Konfigurowanie navigatorów, zagnieżdżanie ich w sobie, ręczne definiowanie każdej ścieżki — to wszystko sprawia, że człowiek zaczyna tęsknić za prostotą Next.js czy Nuxt.js. I właśnie tutaj wkracza Expo Router — rozwiązanie, które przenosi koncepcję file-based routing znaną ze świata webowego frameworków wprost do React Native.

Szczerze mówiąc, to jedna z najważniejszych zmian w ekosystemie React Native ostatnich lat.

W tym przewodniku przeprowadzimy Cię przez wszystko — od podstaw, przez zaawansowane wzorce nawigacji, aż po najnowsze funkcje w wersji v6. Bez zbędnego lania wody, za to z konkretnymi przykładami kodu. No to lecimy.

Czym jest Expo Router i dlaczego file-based routing ma znaczenie?

Expo Router to framework nawigacyjny dla React Native (i aplikacji webowych!), który automatycznie generuje strukturę nawigacji na podstawie plików w katalogu app/. Zamiast ręcznie konfigurować nawigatory i ścieżki, po prostu tworzysz pliki — a router sam wie, co z nimi zrobić.

Brzmi prosto? Bo jest proste. I właśnie o to chodzi.

Tradycyjne podejście w React Navigation wyglądało mniej więcej tak — definiujesz Stack.Navigator, wewnątrz niego Stack.Screen dla każdego ekranu, potem zagnieżdżasz Tab.Navigator, a wewnątrz kolejne Stack.Navigatory... Konfiguracja rozrasta się w ekspresowym tempie, a utrzymanie porządku wymaga żelaznej dyscypliny (i dużo kawy).

File-based routing rozwiązuje ten problem fundamentalnie. Każdy plik w katalogu app/ to osobna ścieżka (route). Struktura katalogów odzwierciedla strukturę nawigacji. Layouty definiujesz w plikach _layout.tsx. Proste, przewidywalne, skalowalne.

  • Automatyczne deep linking — każdy ekran dostaje swój URL automatycznie, bez dodatkowej konfiguracji
  • Typowane trasy — TypeScript wie, jakie ścieżki istnieją w Twojej aplikacji
  • Zunifikowane podejście — ta sama nawigacja działa na iOS, Android i w przeglądarce
  • SEO i SSR — dla wersji webowej dostajesz server-side rendering i meta tagi za darmo
  • Łatwiejsze testowanie — struktura plików jest deterministyczna, więc wiesz dokładnie, co gdzie jest

Jak Expo Router bazuje na React Navigation v7

To ważne, żeby to zrozumieć — Expo Router nie zastępuje React Navigation. On na nim buduje. Pod spodem to nadal te same navigatory (Stack, Tabs, Drawer), te same animacje przejść, ten sam system gestów. Expo Router dodaje warstwę abstrakcji, która automatyzuje konfigurację na podstawie systemu plików.

React Navigation v7 (a właściwie cały ekosystem od wersji 7.x) wprowadził Static API — deklaratywny sposób definiowania nawigatorów, który jest bardziej przyjazny dla narzędzi statycznej analizy. Expo Router intensywnie z tego korzysta. Kiedy tworzysz plik _layout.tsx i eksportujesz z niego komponent ze Stack lub Tabs, Expo Router mapuje to na odpowiednie nawigatory React Navigation.

Co to oznacza w praktyce? Że cała wiedza i doświadczenie, które masz z React Navigation, nadal jest aktualne. Możesz konfigurować opcje ekranów, customizować nagłówki, używać hooków nawigacyjnych — wszystko działa tak jak wcześniej. Expo Router po prostu eliminuje ten cały irytujący boilerplate.

Konfiguracja Expo Router w nowym projekcie (Expo SDK 53/54)

Zacznijmy od początku. Najłatwiejszy sposób to użycie szablonu:

npx create-expo-app@latest moja-aplikacja
cd moja-aplikacja
npx expo start

Od Expo SDK 53 Expo Router jest domyślnym systemem nawigacji — nie musisz nic dodatkowego instalować ani konfigurować. Szablon domyślny już zawiera katalog app/ z podstawową strukturą. Serio, to naprawdę tyle.

Jeśli jednak konfigurujesz projekt ręcznie lub migrujesz istniejący, oto co musisz zrobić:

npx expo install expo-router expo-linking expo-constants expo-status-bar

Następnie w pliku app.json (lub app.config.js) upewnij się, że masz odpowiedni schemat:

{
  "expo": {
    "scheme": "moja-aplikacja",
    "plugins": ["expo-router"],
    "web": {
      "bundler": "metro",
      "output": "server"
    }
  }
}

Kluczowy jest parametr scheme — to on definiuje schemat URL dla deep linków (np. moja-aplikacja://). Parametr web.output: "server" włącza tryb serwerowy dla wersji webowej, co jest potrzebne do API Routes i Server Components.

Na koniec upewnij się, że Twój plik wejściowy (entry point) wskazuje na Expo Router. W package.json:

{
  "main": "expo-router/entry"
}

I gotowe. Naprawdę — to wszystko. Teraz możesz zacząć tworzyć ekrany w katalogu app/.

Kluczowe koncepcje file-based routing

Katalog app/ — serce aplikacji

Każdy plik .tsx (lub .jsx, .ts, .js) w katalogu app/, który eksportuje domyślny komponent React, staje się ekranem w Twojej aplikacji. Ścieżka pliku odpowiada ścieżce URL.

app/
  _layout.tsx        →  Root layout
  index.tsx          →  "/" (ekran główny)
  about.tsx          →  "/about"
  settings/
    _layout.tsx      →  Layout dla settings
    index.tsx        →  "/settings"
    profile.tsx      →  "/settings/profile"
    notifications.tsx →  "/settings/notifications"

Plik index.tsx jest specjalny — odpowiada ścieżce katalogu, w którym się znajduje. Więc app/index.tsx to /, a app/settings/index.tsx to /settings. Intuicyjne, prawda?

Layouty (_layout.tsx)

Layouty to pliki, które opakowują swoje "dzieci" — czyli ekrany w tym samym katalogu (i podkatalogach). To tutaj definiujesz typ nawigacji: Stack, Tabs, Drawer itd.

// app/_layout.tsx
import { Stack } from 'expo-router';

export default function RootLayout() {
  return (
    <Stack>
      <Stack.Screen name="index" options={{ title: 'Strona główna' }} />
      <Stack.Screen name="about" options={{ title: 'O aplikacji' }} />
      <Stack.Screen name="settings" options={{ headerShown: false }} />
    </Stack>
  );
}

Piękne, prawda? Każdy katalog może mieć swój własny _layout.tsx, co pozwala na zagnieżdżanie nawigatorów w sposób naturalny i czytelny.

Grupy — porządkowanie bez wpływu na URL

Czasem chcesz pogrupować ekrany organizacyjnie, ale nie chcesz, żeby nazwa grupy pojawiała się w URL-u. Do tego służą grupy — katalogi w nawiasach okrągłych:

app/
  (tabs)/
    _layout.tsx       →  Tab navigator
    index.tsx         →  "/" (nie "/tabs/")
    explore.tsx       →  "/explore" (nie "/tabs/explore")
  (auth)/
    _layout.tsx       →  Stack dla auth
    login.tsx         →  "/login"
    register.tsx      →  "/register"

Grupy są niewidoczne w ścieżkach URL — służą wyłącznie do organizacji i definiowania layoutów. To niezwykle potężna koncepcja, szczególnie przy budowaniu złożonych aplikacji z różnymi nawigatorami dla różnych sekcji. Kiedyś próbowałem ogarnąć to samo bez grup — nie polecam.

Trasy dynamiczne

Dynamiczne segmenty ścieżek definiujesz za pomocą nawiasów kwadratowych:

app/
  user/
    [id].tsx          →  "/user/123", "/user/abc"
  post/
    [...slug].tsx     →  "/post/2024/hello-world" (catch-all)

W komponencie ekranu odczytujesz parametry za pomocą hooka useLocalSearchParams:

// app/user/[id].tsx
import { useLocalSearchParams } from 'expo-router';
import { Text, View } from 'react-native';

export default function UserScreen() {
  const { id } = useLocalSearchParams<{ id: string }>();

  return (
    <View>
      <Text>Profil użytkownika: {id}</Text>
    </View>
  );
}

Catch-all routes ([...slug]) łapią wszystkie segmenty ścieżki i zwracają je jako tablicę. Przydatne np. dla blogów czy systemów CMS, gdzie ścieżka może mieć dowolną głębokość.

Wzorce nawigacji: Stack, Tabs, Drawer, Modals

Stack Navigation

Stack to najczęstszy wzorzec — ekrany nakładają się na siebie jak karty. Nowy ekran "wjeżdża" na wierzch, a przycisk wstecz zdejmuje go ze stosu. Klasyka.

// app/_layout.tsx
import { Stack } from 'expo-router';

export default function RootLayout() {
  return (
    <Stack
      screenOptions={{
        headerStyle: { backgroundColor: '#1a1a2e' },
        headerTintColor: '#fff',
        headerTitleStyle: { fontWeight: 'bold' },
      }}
    >
      <Stack.Screen name="(tabs)" options={{ headerShown: false }} />
      <Stack.Screen name="modal" options={{ presentation: 'modal' }} />
    </Stack>
  );
}

Tab Navigation

Zakładki na dole ekranu — klasyka aplikacji mobilnych. Definiujesz je w layoucie grupy:

// app/(tabs)/_layout.tsx
import { Tabs } from 'expo-router';
import { Ionicons } from '@expo/vector-icons';

export default function TabsLayout() {
  return (
    <Tabs
      screenOptions={{
        tabBarActiveTintColor: '#6C63FF',
        tabBarStyle: { backgroundColor: '#0f0f23' },
      }}
    >
      <Tabs.Screen
        name="index"
        options={{
          title: 'Główna',
          tabBarIcon: ({ color, size }) => (
            <Ionicons name="home" color={color} size={size} />
          ),
        }}
      />
      <Tabs.Screen
        name="explore"
        options={{
          title: 'Odkrywaj',
          tabBarIcon: ({ color, size }) => (
            <Ionicons name="compass" color={color} size={size} />
          ),
        }}
      />
      <Tabs.Screen
        name="profile"
        options={{
          title: 'Profil',
          tabBarIcon: ({ color, size }) => (
            <Ionicons name="person" color={color} size={size} />
          ),
        }}
      />
    </Tabs>
  );
}

Drawer Navigation

Szuflada nawigacyjna wysuwana z boku ekranu. Wymaga dodatkowego pakietu:

npx expo install @react-navigation/drawer react-native-gesture-handler react-native-reanimated
// app/_layout.tsx
import { Drawer } from 'expo-router/drawer';

export default function DrawerLayout() {
  return (
    <Drawer>
      <Drawer.Screen name="index" options={{ drawerLabel: 'Strona główna' }} />
      <Drawer.Screen name="settings" options={{ drawerLabel: 'Ustawienia' }} />
    </Drawer>
  );
}

Modals

Modale w Expo Router to po prostu ekrany z prezentacją modal. Wystarczy dodać odpowiednią opcję w layoucie:

// app/_layout.tsx
import { Stack } from 'expo-router';

export default function RootLayout() {
  return (
    <Stack>
      <Stack.Screen name="(tabs)" options={{ headerShown: false }} />
      <Stack.Screen
        name="modal"
        options={{
          presentation: 'modal',
          headerTitle: 'Nowy post',
        }}
      />
    </Stack>
  );
}

Aby otworzyć modal z dowolnego miejsca w aplikacji:

import { Link } from 'expo-router';

export default function HomeScreen() {
  return (
    <Link href="/modal" asChild>
      <Pressable>
        <Text>Otwórz modal</Text>
      </Pressable>
    </Link>
  );
}

Transparentne modale, które wyświetlają się nad poprzednim ekranem, uzyskasz za pomocą presentation: 'transparentModal'. Świetne rozwiązanie dla dialogów potwierdzenia, tooltipów czy lekkich formularzy.

Protected Routes i przepływ uwierzytelniania

To jeden z najczęstszych wzorców w aplikacjach mobilnych — część ekranów jest dostępna tylko dla zalogowanych użytkowników. Expo Router oferuje eleganckie rozwiązanie tego problemu, a w wersji v5 pojawiły się nowe mechanizmy, które znacząco to upraszczają.

Klasyczne podejście z redirect

Najprostsze podejście to sprawdzenie stanu uwierzytelnienia w layoucie i przekierowanie użytkownika:

// app/(app)/_layout.tsx
import { Redirect, Stack } from 'expo-router';
import { useAuth } from '../../hooks/useAuth';

export default function AppLayout() {
  const { user, isLoading } = useAuth();

  if (isLoading) {
    return <LoadingScreen />;
  }

  if (!user) {
    return <Redirect href="/login" />;
  }

  return (
    <Stack>
      <Stack.Screen name="(tabs)" options={{ headerShown: false }} />
    </Stack>
  );
}

Działa? Działa. Ale da się lepiej.

Stack.Protected i guard prop (nowość w v5)

Expo Router v5 wprowadził nowy, bardziej deklaratywny sposób ochrony tras — Stack.Protected z propem guard. To podejście jest czytelniejsze i lepiej integruje się z systemem nawigacji:

// app/_layout.tsx
import { Stack } from 'expo-router';
import { useAuth } from '../hooks/useAuth';

export default function RootLayout() {
  const { user } = useAuth();

  return (
    <Stack>
      <Stack.Protected guard={!user}>
        <Stack.Screen name="login" />
        <Stack.Screen name="register" />
      </Stack.Protected>

      <Stack.Protected guard={!!user}>
        <Stack.Screen name="(tabs)" options={{ headerShown: false }} />
        <Stack.Screen name="settings" />
      </Stack.Protected>
    </Stack>
  );
}

Prop guard przyjmuje wartość boolean. Kiedy jest true, ekrany wewnątrz Stack.Protected są dostępne. Kiedy false — są ukryte. System nawigacji automatycznie przekieruje użytkownika do pierwszego dostępnego ekranu, jeśli aktualny stanie się niedostępny.

To eleganckie podejście eliminuje konieczność ręcznego zarządzania przekierowaniami. Router sam wie, które ekrany pokazać na podstawie stanu guard. Mniej kodu, mniej bugów — win-win.

Typed Routes — bezpieczeństwo typów dla tras

Jeśli używasz TypeScriptu (a powinieneś!), Typed Routes to funkcja, na którą czekałeś. Expo Router automatycznie generuje typy dla wszystkich tras w Twojej aplikacji, co oznacza, że IDE podpowie Ci dostępne ścieżki, a kompilator złapie literówki w nazwach tras.

Aby włączyć Typed Routes, dodaj do konfiguracji:

// app.json
{
  "expo": {
    "experiments": {
      "typedRoutes": true
    }
  }
}

Po uruchomieniu serwera deweloperskiego (npx expo start), Expo Router wygeneruje plik z typami. Od tego momentu:

import { Link, router } from 'expo-router';

// TypeScript zna wszystkie trasy w aplikacji
<Link href="/settings/profile" />     // OK
<Link href="/settings/proflie" />     // Błąd! Literówka!

// To samo dotyczy programatycznej nawigacji
router.push('/user/123');               // OK
router.push('/uzer/123');               // Błąd!

// Parametry dynamicznych tras też są typowane
router.push({ pathname: '/user/[id]', params: { id: '123' } });

Typed Routes działają naprawdę dobrze. Szczególnie w większych projektach, gdzie ręczne śledzenie wszystkich ścieżek jest praktycznie niemożliwe — ta funkcja jest na wagę złota. Ile razy miałem buga, bo gdzieś wkradła się literówka w nazwie trasy... no, nie chcę nawet liczyć.

Wygenerowane typy znajdziesz w pliku .expo/types/router.d.ts. Plik ten jest automatycznie aktualizowany przy każdej zmianie struktury plików w katalogu app/.

API Routes — full-stack development w Expo

To jedna z najbardziej ekscytujących funkcji Expo Router. API Routes pozwalają Ci pisać kod serwerowy bezpośrednio w projekcie Expo — bez potrzeby oddzielnego backendu. Tworzysz pliki w katalogu app/ z rozszerzeniem +api.ts, a one stają się endpointami HTTP.

// app/api/users+api.ts
export async function GET(request: Request) {
  const users = await db.query('SELECT * FROM users');
  return Response.json(users);
}

export async function POST(request: Request) {
  const body = await request.json();
  const { name, email } = body;

  const user = await db.insert('users', { name, email });
  return Response.json(user, { status: 201 });
}

Nazwy eksportowanych funkcji odpowiadają metodom HTTP: GET, POST, PUT, DELETE, PATCH. Każda funkcja otrzymuje standardowy obiekt Request i zwraca Response — dokładnie jak w Web API.

Dynamiczne trasy API działają tak samo jak trasy ekranów:

// app/api/users/[id]+api.ts
export async function GET(request: Request, { id }: { id: string }) {
  const user = await db.findOne('users', { id });

  if (!user) {
    return Response.json(
      { error: 'Użytkownik nie znaleziony' },
      { status: 404 }
    );
  }

  return Response.json(user);
}

export async function DELETE(request: Request, { id }: { id: string }) {
  await db.delete('users', { id });
  return new Response(null, { status: 204 });
}

API Routes uruchamiają się na serwerze — możesz bezpiecznie używać w nich sekretów, łączyć się z bazami danych, wywoływać zewnętrzne API. To otwiera drogę do prawdziwie full-stackowego developmentu w jednym projekcie.

Pamiętaj jednak, że API Routes działają tylko w trybie web.output: "server". W aplikacjach natywnych (iOS, Android) musisz hostować serwer osobno — np. na Vercel, Netlify, lub własnym serwerze. To taki drobny haczyk, o którym warto wiedzieć od początku.

React Server Components (eksperymentalne)

Expo Router eksperymentuje z React Server Components (RSC) — to przełomowa technologia, która pozwala renderować komponenty na serwerze i przesyłać ich wynik do klienta. W kontekście React Native oznacza to mniejsze bundle, szybsze ładowanie danych i prostszy kod.

// app/feed.tsx (Server Component — domyślnie)
import { Text, View } from 'react-native';

// Ten komponent renderuje się na serwerze!
// Możesz bezpośrednio pobierać dane, bez useEffect/useState
export default async function FeedScreen() {
  const posts = await fetch('https://api.example.com/posts')
    .then(res => res.json());

  return (
    <View>
      {posts.map(post => (
        <View key={post.id}>
          <Text style={{ fontWeight: 'bold' }}>{post.title}</Text>
          <Text>{post.body}</Text>
        </View>
      ))}
    </View>
  );
}

Żeby oznaczyć komponent jako kliencki (z interakcjami, stanem, efektami), dodajesz dyrektywę 'use client' na początku pliku:

'use client';

import { useState } from 'react';
import { Button, Text, View } from 'react-native';

export default function LikeButton({ postId }: { postId: string }) {
  const [liked, setLiked] = useState(false);

  return (
    <Button
      title={liked ? 'Polubiono' : 'Polub'}
      onPress={() => setLiked(!liked)}
    />
  );
}

RSC w Expo Router to wciąż funkcja eksperymentalna i nie jest gotowa do produkcji. Ale kierunek jest jasny — przyszłość React Native to konwergencja z webowymi wzorcami renderowania. Warto śledzić rozwój tej funkcji i zacząć się z nią oswajać już teraz.

Deep Linking — automatyczne uniwersalne linki

To jeden z największych atutów Expo Router — każdy ekran automatycznie dostaje swój deep link. Nie musisz konfigurować linkingConfig, mapować ścieżek na ekrany ani nic w tym stylu. Struktura plików = struktura URL-i = deep linki. Koniec, kropka.

Jeśli masz plik app/user/[id].tsx, to link moja-aplikacja://user/42 automatycznie otworzy ten ekran z parametrem id = '42'. Bez żadnej dodatkowej konfiguracji.

Dla Universal Links (iOS) i App Links (Android), które działają z domenami HTTPS, musisz skonfigurować intentFilters w app.json:

{
  "expo": {
    "scheme": "moja-aplikacja",
    "android": {
      "intentFilters": [
        {
          "action": "VIEW",
          "autoVerify": true,
          "data": [
            {
              "scheme": "https",
              "host": "moja-domena.pl",
              "pathPrefix": "/"
            }
          ],
          "category": ["BROWSABLE", "DEFAULT"]
        }
      ]
    },
    "ios": {
      "associatedDomains": ["applinks:moja-domena.pl"]
    }
  }
}

Expo Router obsługuje też Headless deep linking — możesz przechwycić link i zdecydować, co z nim zrobić, zanim użytkownik zobaczy jakikolwiek ekran. Przydatne np. do weryfikacji tokenów z emaili potwierdzających.

W środowisku deweloperskim możesz testować deep linki za pomocą:

# iOS Simulator
npx uri-scheme open "moja-aplikacja://user/42" --ios

# Android Emulator
adb shell am start -a android.intent.action.VIEW -d "moja-aplikacja://user/42"

Optymalizacja wydajności

Lazy Evaluation

Domyślnie Expo Router ładuje wszystkie ekrany eagerly — co oznacza, że przy starcie aplikacji każdy plik w app/ jest importowany. Dla dużych aplikacji to może być spory problem. Na szczęście możesz włączyć leniwe ładowanie:

// app/_layout.tsx
import { Stack } from 'expo-router';

export default function RootLayout() {
  return (
    <Stack screenOptions={{ lazy: true }}>
      <Stack.Screen name="(tabs)" />
      <Stack.Screen name="settings" />
      <Stack.Screen name="premium-features" />
    </Stack>
  );
}

Z opcją lazy: true ekrany są ładowane dopiero, gdy użytkownik do nich nawiguje. To znacząco redukuje czas startu aplikacji.

Async Routes (Bundle Splitting)

Async Routes to bardziej zaawansowana funkcja, która dzieli bundle aplikacji na mniejsze części (chunks). Każdy ekran może być ładowany asynchronicznie — użytkownik pobiera tylko kod, którego aktualnie potrzebuje.

// metro.config.js
const { getDefaultConfig } = require('expo/metro-config');

const config = getDefaultConfig(__dirname);

config.transformer = {
  ...config.transformer,
  asyncRequireModulePath: require.resolve(
    'expo-router/async-require'
  ),
};

module.exports = config;

Na webie bundle splitting działa świetnie — każda strona to osobny chunk JS. Na natywnych platformach efekty są mniej spektakularne (bo i tak cały bundle jest na urządzeniu), ale nadal pomagają przy startupie aplikacji.

Optymalizacja re-renderów

Kilka praktycznych wskazówek dotyczących wydajności nawigacji:

  • Używaj useLocalSearchParams zamiast useSearchParams — wersja "local" nie powoduje re-renderów, gdy zmieniają się parametry w innym ekranie na stosie
  • Memoizuj ciężkie ekranyReact.memo() na komponentach ekranów zapobiega niepotrzebnym re-renderom przy nawigacji
  • Unikaj ciężkich operacji w _layout.tsx — layouty renderują się przy każdej nawigacji w swoim zakresie
  • Używaj unstable_settings do kontroli initial route — to pozwala zdefiniować, który ekran jest domyślny w danej grupie
// app/(tabs)/_layout.tsx
export const unstable_settings = {
  initialRouteName: 'index',
};

export default function TabsLayout() {
  return (
    <Tabs>
      {/* ... */}
    </Tabs>
  );
}

Praktyczny przykład — przepływ uwierzytelniania z zakładkami

Czas na kompletny, praktyczny przykład. Zbudujemy aplikację z ekranami logowania i rejestracji, które po uwierzytelnieniu przekierowują do głównej aplikacji z trzema zakładkami. To chyba najczęstszy scenariusz, na jaki trafiam w komercyjnych projektach.

Oto struktura plików:

app/
  _layout.tsx           →  Root Stack (ochrona tras)
  (auth)/
    _layout.tsx         →  Stack dla auth
    login.tsx           →  Ekran logowania
    register.tsx        →  Ekran rejestracji
  (app)/
    _layout.tsx         →  Tab navigator
    index.tsx           →  Zakładka "Główna"
    search.tsx          →  Zakładka "Szukaj"
    profile.tsx         →  Zakładka "Profil"
  modal.tsx             →  Globalny modal

contexts/
  AuthContext.tsx        →  Kontekst uwierzytelniania

Zacznijmy od kontekstu uwierzytelniania:

// contexts/AuthContext.tsx
import React, { createContext, useContext, useState, useEffect } from 'react';
import * as SecureStore from 'expo-secure-store';

interface AuthContextType {
  user: { id: string; name: string; email: string } | null;
  isLoading: boolean;
  signIn: (email: string, password: string) => Promise<void>;
  signUp: (name: string, email: string, password: string) => Promise<void>;
  signOut: () => Promise<void>;
}

const AuthContext = createContext<AuthContextType | null>(null);

export function AuthProvider({ children }: { children: React.ReactNode }) {
  const [user, setUser] = useState<AuthContextType['user']>(null);
  const [isLoading, setIsLoading] = useState(true);

  useEffect(() => {
    loadStoredAuth();
  }, []);

  async function loadStoredAuth() {
    try {
      const token = await SecureStore.getItemAsync('auth_token');
      if (token) {
        const response = await fetch('https://api.example.com/me', {
          headers: { Authorization: `Bearer ${token}` },
        });
        if (response.ok) {
          const userData = await response.json();
          setUser(userData);
        }
      }
    } catch (error) {
      console.error('Błąd ładowania sesji:', error);
    } finally {
      setIsLoading(false);
    }
  }

  async function signIn(email: string, password: string) {
    const response = await fetch('https://api.example.com/login', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ email, password }),
    });
    if (!response.ok) throw new Error('Nieprawidłowe dane logowania');
    const { token, user: userData } = await response.json();
    await SecureStore.setItemAsync('auth_token', token);
    setUser(userData);
  }

  async function signUp(name: string, email: string, password: string) {
    const response = await fetch('https://api.example.com/register', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ name, email, password }),
    });
    if (!response.ok) throw new Error('Błąd rejestracji');
    const { token, user: userData } = await response.json();
    await SecureStore.setItemAsync('auth_token', token);
    setUser(userData);
  }

  async function signOut() {
    await SecureStore.deleteItemAsync('auth_token');
    setUser(null);
  }

  return (
    <AuthContext.Provider value={{ user, isLoading, signIn, signUp, signOut }}>
      {children}
    </AuthContext.Provider>
  );
}

export function useAuth() {
  const context = useContext(AuthContext);
  if (!context) throw new Error('useAuth musi być użyty wewnątrz AuthProvider');
  return context;
}

Teraz root layout z ochroną tras:

// app/_layout.tsx
import { Stack } from 'expo-router';
import { AuthProvider, useAuth } from '../contexts/AuthContext';
import { ActivityIndicator, View } from 'react-native';

function RootLayoutNav() {
  const { user, isLoading } = useAuth();

  if (isLoading) {
    return (
      <View style={{ flex: 1, justifyContent: 'center', alignItems: 'center' }}>
        <ActivityIndicator size="large" color="#6C63FF" />
      </View>
    );
  }

  return (
    <Stack screenOptions={{ headerShown: false }}>
      <Stack.Protected guard={!user}>
        <Stack.Screen name="(auth)" />
      </Stack.Protected>

      <Stack.Protected guard={!!user}>
        <Stack.Screen name="(app)" />
        <Stack.Screen
          name="modal"
          options={{
            headerShown: true,
            presentation: 'modal',
            headerTitle: 'Nowy wpis',
          }}
        />
      </Stack.Protected>
    </Stack>
  );
}

export default function RootLayout() {
  return (
    <AuthProvider>
      <RootLayoutNav />
    </AuthProvider>
  );
}

Layout zakładek głównej aplikacji:

// app/(app)/_layout.tsx
import { Tabs } from 'expo-router';
import { Ionicons } from '@expo/vector-icons';

export const unstable_settings = {
  initialRouteName: 'index',
};

export default function AppLayout() {
  return (
    <Tabs
      screenOptions={{
        tabBarActiveTintColor: '#6C63FF',
        tabBarInactiveTintColor: '#666',
        tabBarStyle: {
          backgroundColor: '#0f0f23',
          borderTopColor: '#1a1a2e',
        },
        headerStyle: { backgroundColor: '#0f0f23' },
        headerTintColor: '#fff',
      }}
    >
      <Tabs.Screen
        name="index"
        options={{
          title: 'Główna',
          tabBarIcon: ({ color, size }) => (
            <Ionicons name="home-outline" color={color} size={size} />
          ),
        }}
      />
      <Tabs.Screen
        name="search"
        options={{
          title: 'Szukaj',
          tabBarIcon: ({ color, size }) => (
            <Ionicons name="search-outline" color={color} size={size} />
          ),
        }}
      />
      <Tabs.Screen
        name="profile"
        options={{
          title: 'Profil',
          tabBarIcon: ({ color, size }) => (
            <Ionicons name="person-outline" color={color} size={size} />
          ),
        }}
      />
    </Tabs>
  );
}

Zwróć uwagę na elegancję tego rozwiązania. Cały przepływ uwierzytelniania sprowadza się do jednego warunku w guard. Kiedy użytkownik się loguje, stan user zmienia się z null na obiekt, guardy się przeliczają i router automatycznie nawiguje do odpowiedniej grupy ekranów. Żadnych ręcznych router.replace(). Magia? Nie, po prostu dobrze zaprojektowane API.

Migracja z React Navigation

Jeśli masz istniejący projekt z React Navigation, migracja do Expo Router jest w pełni wykonalna, choć wymaga pewnego planowania. Oto kluczowe kroki:

1. Mapowanie nawigatorów na strukturę plików

Każdy navigator w Twoim kodzie odpowiada layoutowi w Expo Router:

  • createStackNavigator()Stack w _layout.tsx
  • createBottomTabNavigator()Tabs w _layout.tsx
  • createDrawerNavigator()Drawer w _layout.tsx

2. Przenoszenie ekranów

Każdy komponent Stack.Screen z React Navigation staje się osobnym plikiem. Jeśli miałeś:

// Stary kod React Navigation
<Stack.Navigator>
  <Stack.Screen name="Home" component={HomeScreen} />
  <Stack.Screen name="Details" component={DetailsScreen} />
  <Stack.Screen name="Settings" component={SettingsScreen} />
</Stack.Navigator>

To tworzysz:

app/
  _layout.tsx     →  Stack layout
  index.tsx       →  HomeScreen (dawne "Home")
  details.tsx     →  DetailsScreen
  settings.tsx    →  SettingsScreen

3. Aktualizacja nawigacji

Zamień navigation.navigate() na router.push() lub komponenty Link:

// Stary kod
navigation.navigate('Details', { id: 123 });

// Nowy kod z Expo Router
import { router } from 'expo-router';
router.push({ pathname: '/details', params: { id: '123' } });

// Lub deklaratywnie
import { Link } from 'expo-router';
<Link href={{ pathname: '/details', params: { id: '123' } }}>
  Szczegóły
</Link>

4. Zamiana hooków

  • useNavigation()router (importowany z expo-router)
  • useRoute().paramsuseLocalSearchParams()
  • useFocusEffect() → nadal działa (jest re-eksportowany przez expo-router)
  • useIsFocused() → nadal działa

Migracja nie musi być "wszystko albo nic". Możesz stopniowo przenosić ekrany do katalogu app/, zaczynając od nowych funkcji i sukcesywnie migrując istniejące. Expo Router jest kompatybilny z React Navigation, więc oba podejścia mogą współistnieć w trakcie przejścia. Spokojnie, nie musisz tego robić w jeden weekend.

Co nowego w Expo Router v6

Wersja v6, dostarczana razem z Expo SDK 54, przynosi kilka fascynujących nowości:

NativeTabs — natywne zakładki dla iOS 26

To chyba najgłośniejsza nowość. Apple w iOS 26 wprowadził nowy wygląd zakładek — zaokrąglony pasek na dole ekranu z efektem liquid glass i animacjami przy przełączaniu. Expo Router v6 wspiera to natywnie:

// app/(tabs)/_layout.tsx
import { Tabs } from 'expo-router';

export default function TabsLayout() {
  return (
    <Tabs
      tabBar="native"
      screenOptions={{
        tabBarActiveTintColor: '#6C63FF',
      }}
    >
      <Tabs.Screen
        name="index"
        options={{
          title: 'Główna',
          tabBarIcon: { sfSymbol: 'house.fill' },
        }}
      />
      <Tabs.Screen
        name="search"
        options={{
          title: 'Szukaj',
          tabBarIcon: { sfSymbol: 'magnifyingglass' },
        }}
      />
      <Tabs.Screen
        name="profile"
        options={{
          title: 'Profil',
          tabBarIcon: { sfSymbol: 'person.fill' },
        }}
      />
    </Tabs>
  );
}

Prop tabBar="native" aktywuje natywny wygląd zakładek iOS 26 — to tzw. liquid glass bottom tabs. Na starszych wersjach iOS i na Androidzie aplikacja automatycznie wraca do klasycznego wyglądu. Dostajesz najnowszy design Apple bez pisania ani jednej linii kodu platformowo-specyficznego. Jak to nie jest piękne?

Ulepszone Server Components

W v6 React Server Components przeszły kolejną iterację. Lepsze wsparcie dla streamingu, ulepszone cache'owanie i bliższa integracja z natywnym renderingiem. To wciąż eksperyment, ale z każdą wersją staje się coraz bardziej stabilny.

Lepsza wydajność cold start

Expo Router v6 wprowadza agresywniejsze lazy loading i tree shaking dla natywnych bundli. W testach czas pierwszego renderowania spadł o 15-25% w porównaniu z v5 dla aplikacji ze złożoną strukturą nawigacji. W połączeniu z prekompilowanymi XCFrameworks z Expo SDK 54, czasy buildów na iOS mogą być nawet 10-krotnie szybsze. Te liczby robią wrażenie.

Podsumowanie i najlepsze praktyki

Expo Router to nie jest "kolejna biblioteka nawigacji". To fundamentalna zmiana w podejściu do budowania aplikacji React Native. File-based routing, automatyczne deep linki, API Routes, Server Components — to wszystko razem tworzy spójny ekosystem, który zbliża React Native do dojrzałości frameworków webowych.

Oto zbiór najlepszych praktyk, które warto stosować:

Struktura projektu

  • Trzymaj logikę biznesową poza katalogiem app/ — pliki w app/ powinny być "cienkimi" ekranami, które importują komponenty i hooki z zewnętrznych katalogów
  • Używaj grup do organizacji(tabs), (auth), (admin) — to nie tylko czytelność, ale też lepsze zarządzanie layoutami
  • Jeden plik = jeden ekran — unikaj eksportowania wielu komponentów ekranów z jednego pliku
  • Nazywaj pliki małymi literami z myślnikamiuser-profile.tsx zamiast UserProfile.tsx, bo nazwy plików stają się URL-ami

Nawigacja

  • Preferuj Link nad router.push() — deklaratywna nawigacja jest bardziej przewidywalna i lepsza dla accessibility
  • Używaj router.replace() dla przekierowań — np. po logowaniu, żeby użytkownik nie mógł wrócić do ekranu logowania przyciskiem wstecz
  • Zawsze definiuj unstable_settings.initialRouteName — to zapobiega nieoczekiwanemu zachowaniu przy deep linkach
  • Nie zagnieżdżaj nawigatorów głębiej niż 3 poziomy — jeśli potrzebujesz głębszego zagnieżdżenia, prawdopodobnie struktura wymaga przemyślenia

Wydajność

  • Włącz lazy loading dla Stack — szczególnie ważne, jeśli masz wiele ekranów
  • Rozważ Async Routes dla dużych aplikacji — bundle splitting robi ogromną różnicę na webie
  • Używaj useLocalSearchParams — to zapobiega kaskadowym re-renderom
  • Profiluj z React DevTools — czasem problem wydajnościowy leży w komponencie ekranu, nie w nawigacji

Deep Linking i UX

  • Testuj deep linki na każdym etapie — to najłatwiejsze miejsce, żeby coś przeoczyć
  • Zawsze obsługuj ekran 404 — stwórz plik app/+not-found.tsx dla nieznanych ścieżek
  • Pamiętaj o stanie ładowania — użytkownicy wchodzący przez deep link nie widzą splash screena tak jak przy normalnym uruchomieniu
// app/+not-found.tsx
import { Link, Stack } from 'expo-router';
import { View, Text, StyleSheet } from 'react-native';

export default function NotFoundScreen() {
  return (
    <>
      <Stack.Screen options={{ title: 'Ups!' }} />
      <View style={styles.container}>
        <Text style={styles.title}>Ta strona nie istnieje</Text>
        <Link href="/" style={styles.link}>
          Wróć na stronę główną
        </Link>
      </View>
    </>
  );
}

const styles = StyleSheet.create({
  container: { flex: 1, justifyContent: 'center', alignItems: 'center', backgroundColor: '#0f0f23' },
  title: { fontSize: 20, fontWeight: 'bold', color: '#fff' },
  link: { color: '#6C63FF', marginTop: 16, fontSize: 16 },
});

Expo Router zmienia sposób, w jaki myślimy o nawigacji w React Native. Zamiast ręcznego konfigurowania grafów nawigacji, po prostu tworzysz pliki. Zamiast walczenia z deep linkami, dostajesz je za darmo. Zamiast oddzielnego backendu, piszesz API Routes w tym samym projekcie.

Czy to idealne rozwiązanie dla każdego projektu? Nie — jeśli potrzebujesz bardzo specyficznej, niestandardowej nawigacji, React Navigation daje Ci więcej kontroli na niskim poziomie. Ale dla zdecydowanej większości aplikacji Expo Router to po prostu lepszy wybór. Szybszy start, mniej kodu, więcej automatyzacji, lepsze DX.

Szczerze? Po przesiadce na Expo Router ciężko wrócić do ręcznego konfigurowania nawigatorów. To trochę jak przesiadka z Webpacka na Vite — niby robiłeś to samo, ale nagle wszystko jest prostsze i szybsze. I o to właśnie chodzi.

O Autorze Editorial Team

Our team of expert writers and editors.