Expo Router в 2026: полное руководство по файловой маршрутизации в React Native

Полное руководство по Expo Router в 2026: файловая маршрутизация, динамические маршруты, аутентификация через Stack.Protected, Native Tabs, SplitView и рабочие примеры кода для Expo SDK 55.

Введение: почему файловая маршрутизация — будущее навигации в React Native

Навигация — это сердце любого мобильного приложения. Без неё пользователь застрянет на одном экране, а вы застрянете без пользователей. Годами мы в экосистеме React Native конфигурировали навигаторы вручную через React Navigation: создавали стеки, вкладки, вложенные навигаторы, прописывали каждый экран руками. Работало? Ну да. Было ли это удобно? Если честно, не особо — особенно когда проект разрастался до десятков экранов и ты начинал путаться в конфигах.

В 2026 году ситуация изменилась кардинально. Expo Router принёс в React Native концепцию файловой маршрутизации, которую веб-разработчики уже давно знают по Next.js и Remix. Суть проста до безобразия: структура файлов в директории app/ определяет навигационную структуру приложения. Создали файл — получили маршрут. Создали папку — получили вложенный путь. Никакой ручной конфигурации.

Но Expo Router — это не просто «файлы вместо конфига». Это полноценный навигационный фреймворк, построенный поверх React Navigation, который автоматически генерирует навигационное дерево, обеспечивает типобезопасность маршрутов, автоматический deep linking и даже серверный рендеринг для веба. А с выходом Expo SDK 55 и React Native 0.83.1 всё это стало ещё стабильнее.

В этом руководстве мы разберём Expo Router от и до — от базовой настройки до продвинутых сценариев вроде аутентификации, динамических маршрутов и нового SplitView API для планшетов. Каждый раздел с рабочими примерами кода, которые можно использовать прямо сегодня.

Настройка Expo Router с Expo SDK 55

Expo SDK 55, вышедший в начале 2026 года, — серьёзный релиз. Под капотом React Native 0.83.1 и React 19.2, а поддержка старой архитектуры полностью удалена. Всё работает на New Architecture: Fabric, TurboModules, Bridgeless Mode. Плюс появился Hermes V1 — новая мажорная версия JavaScript-движка.

Итак, давайте создадим новый проект. Это буквально одна команда:

# Создание нового проекта с шаблоном навигации
npx create-expo-app@latest my-app --template tabs

# Переход в директорию проекта
cd my-app

# Запуск dev-сервера
npx expo start

Шаблон tabs создаст проект с уже настроенным Expo Router и табами. Но если вы хотите добавить Expo Router в существующий проект, установите нужные пакеты:

# Установка Expo Router и зависимостей
npx expo install expo-router expo-linking expo-constants expo-status-bar

Далее обновите app.json — здесь указывается схема для deep linking, включаются типизированные маршруты и настраивается бандлер:

{
  "expo": {
    "name": "my-app",
    "slug": "my-app",
    "scheme": "myapp",
    "web": {
      "bundler": "metro",
      "output": "server"
    },
    "plugins": ["expo-router"],
    "experiments": {
      "typedRoutes": true
    }
  }
}

И укажите точку входа в package.json:

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

После этого Expo Router начнёт автоматически сканировать директорию app/ и генерировать навигацию на основе файловой структуры. Всё работает из коробки — никаких дополнительных плясок.

Важные нюансы SDK 55

Раз уж мы заговорили про SDK 55, стоит отметить несколько вещей, которые точно повлияют на вашу работу:

  • Legacy Architecture удалена полностью — если у вас были старые нативные модули на Bridge, их придётся мигрировать на TurboModules. Обратного пути нет.
  • React 19.2 — доступны все новые фишки React, включая Server Components (для веба) и улучшенный Suspense.
  • Hermes V1 — новая версия движка, совместимая с ECMAScript 2024, быстрее запуск и меньше потребление памяти.
  • Синхронное обновление экранов — по умолчанию экраны обновляются синхронно, что устраняет то раздражающее мерцание при навигации.

Ключевые концепции файловой маршрутизации

Ядро Expo Router — директория app/ в корне проекта. Каждый файл, экспортирующий React-компонент по умолчанию, становится маршрутом. Имя файла равно URL-путь.

Просто и элегантно.

Базовая структура директории

app/
├── _layout.tsx          # Корневой layout (обязательный)
├── index.tsx            # Главный экран (путь: /)
├── about.tsx            # О приложении (путь: /about)
├── settings.tsx         # Настройки (путь: /settings)
└── profile/
    ├── _layout.tsx      # Layout секции профиля
    ├── index.tsx        # Профиль (путь: /profile)
    └── edit.tsx         # Редактирование (путь: /profile/edit)

Видите закономерность? Файл index.tsx в любой папке — это корневой маршрут этой папки. Подпапки создают вложенные пути. А файлы _layout.tsx определяют, как отображаются дочерние экраны — это, по сути, аналоги навигаторов из React Navigation.

Файлы _layout.tsx: навигаторы «по-новому»

Layout-файлы — пожалуй, самая важная концепция в Expo Router. Они определяют навигационную оболочку для всех экранов в своей директории. Корневой app/_layout.tsx обязателен — это точка входа в навигацию всего приложения.

// app/_layout.tsx — корневой layout приложения
import { Stack } from 'expo-router';

export default function RootLayout() {
  return (
    
      
      
      
    
  );
}

Кстати, вы можете не перечислять Stack.Screen явно — Expo Router автоматически подхватит все файлы в директории. Но явное объявление полезно, когда нужно задать параметры конкретных экранов (заголовок, анимации и т.д.).

Группы маршрутов с (скобками)

Иногда нужно объединить экраны в группу, но при этом не хочется, чтобы это влияло на URL. Для этого есть директории в круглых скобках — они не попадают в путь маршрута:

app/
├── _layout.tsx
├── (auth)/
│   ├── _layout.tsx
│   ├── login.tsx        # путь: /login (НЕ /auth/login!)
│   └── register.tsx     # путь: /register
├── (tabs)/
│   ├── _layout.tsx
│   ├── index.tsx        # путь: /
│   ├── explore.tsx      # путь: /explore
│   └── profile.tsx      # путь: /profile
└── modal.tsx            # путь: /modal

Группы идеально подходят для организации кода без создания лишних уровней вложенности в URL. Например, экраны аутентификации живут в папке (auth), но их URL не содержат «auth» — пользователь видит просто /login и /register. Удобно, правда?

Скрытие файлов от маршрутизатора

Не всякий файл в директории app/ должен становиться маршрутом. Компоненты, утилиты, хуки — всё это можно спокойно размещать рядом с маршрутами, если правильно их именовать:

  • Файлы и папки с _ (подчёркивание) в начале имени игнорируются маршрутизатором (кроме _layout.tsx)
  • Тестовые файлы (*.test.tsx) тоже игнорируются
  • Файлы без default export не создают маршруты
app/
├── _layout.tsx          # Layout — обрабатывается особым образом
├── _utils/              # Утилиты — игнорируется маршрутизатором
│   └── helpers.ts
├── _components/         # Компоненты — игнорируется
│   └── Header.tsx
├── index.tsx            # Маршрут: /
└── about.tsx            # Маршрут: /about

Навигационные layout-ы: Stack, Tabs и Drawer

Expo Router предоставляет три основных типа навигационных layout-ов. Давайте разберём каждый.

Stack — стековая навигация

Stack — самый распространённый тип навигации. Экраны «складываются» один поверх другого, а кнопка «Назад» возвращает на предыдущий. Классика мобильного UX.

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

export default function RootLayout() {
  return (
    
      
      
      
    
  );
}

Tabs — навигация вкладками

Табы — классический паттерн мобильного UI. В 2026 году Expo Router предлагает два варианта: стандартные Tabs и новые Native Tabs.

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

export default function TabsLayout() {
  return (
    
       (
            
          ),
        }}
      />
       (
            
          ),
        }}
      />
       (
            
          ),
        }}
      />
    
  );
}

Native Tabs — нативные вкладки

А вот это — новинка, которая лично меня впечатлила. Native Tabs используют нативный UITabBarController на iOS и BottomNavigationView на Android вместо JavaScript-реализации. Результат — мгновенный отклик, нативные анимации и автоматическая обработка safe area insets. Больше никаких танцев с SafeAreaView для таб-бара!

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

export default function NativeTabsLayout() {
  return (
    
       (
            
          ),
        }}
      />
       (
            
          ),
        }}
      />
       (
            
          ),
        }}
      />
    
  );
}

Ключевое преимущество Native Tabs — safe area insets обрабатываются автоматически на платформенном уровне. Не нужно вручную добавлять отступы или заворачивать контент в SafeAreaView. Платформа сама разберётся.

Drawer — боковое меню

Drawer отлично подходит для приложений с большим количеством разделов. Для использования нужно поставить дополнительный пакет:

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

export default function DrawerLayout() {
  return (
    
      
        
        
      
    
  );
}

Динамические маршруты: [param] и [...rest]

Статические маршруты — это замечательно, но в реальных приложениях почти всегда нужны динамические параметры. Expo Router поддерживает их через специальное именование файлов в квадратных скобках.

Параметры маршрута [param]

Файл с именем в квадратных скобках создаёт динамический сегмент пути. Например, [id].tsx будет матчить любое значение на этой позиции в URL:

app/
└── products/
    ├── _layout.tsx
    ├── index.tsx         # /products
    └── [id].tsx          # /products/123, /products/abc, /products/что-угодно

Получить значение параметра внутри компонента можно через хук useLocalSearchParams:

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

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

  return (
    
      
      Товар: {id}
      
        Здесь загружаются данные товара по ID
      
    
  );
}

const styles = StyleSheet.create({
  container: { flex: 1, padding: 20, backgroundColor: '#0f0f23' },
  title: { fontSize: 24, fontWeight: 'bold', color: '#fff' },
  subtitle: { fontSize: 16, color: '#aaa', marginTop: 8 },
});

Вложенные динамические параметры

Можно создавать несколько уровней динамических параметров — и это работает именно так, как вы ожидаете:

app/
└── users/
    └── [userId]/
        ├── _layout.tsx
        ├── index.tsx       # /users/42
        └── posts/
            └── [postId].tsx # /users/42/posts/7
// app/users/[userId]/posts/[postId].tsx
import { useLocalSearchParams } from 'expo-router';

export default function UserPostScreen() {
  const { userId, postId } = useLocalSearchParams<{
    userId: string;
    postId: string;
  }>();

  return (
    
      Пользователь: {userId}
      Запись: {postId}
    
  );
}

Catch-all маршруты [...rest]

Если нужно перехватить произвольное количество сегментов пути — используйте catch-all маршрут с [...rest]. Это полезно для кастомных 404-страниц или обработки произвольных URL:

app/
├── _layout.tsx
├── index.tsx
└── [...missing].tsx     # Перехватывает все неопределённые пути
// app/[...missing].tsx
import { View, Text, StyleSheet } from 'react-native';
import { Link, useLocalSearchParams } from 'expo-router';

export default function NotFoundScreen() {
  const { missing } = useLocalSearchParams<{ missing: string[] }>();

  return (
    
      404
      Страница не найдена
      
        Запрошенный путь: /{missing?.join('/')}
      
      
        Вернуться на главную
      
    
  );
}

const styles = StyleSheet.create({
  container: { flex: 1, justifyContent: 'center', alignItems: 'center', backgroundColor: '#0f0f23' },
  emoji: { fontSize: 64, fontWeight: 'bold', color: '#6C63FF' },
  title: { fontSize: 24, fontWeight: 'bold', color: '#fff', marginTop: 16 },
  path: { fontSize: 14, color: '#999', marginTop: 8 },
  link: { fontSize: 16, color: '#6C63FF', marginTop: 24, textDecorationLine: 'underline' },
});

Обратите внимание: параметр catch-all маршрута — это массив строк, а не одна строка. Путь /foo/bar/baz передаст missing как ['foo', 'bar', 'baz'].

Типизированные маршруты для TypeScript

Одна из самых полезных (я бы даже сказал — одна из самых недооценённых) возможностей Expo Router — автоматическая генерация типов маршрутов. Когда в app.json включена опция typedRoutes: true, Expo CLI генерирует типы при каждом изменении файловой структуры. Это даёт автодополнение в IDE и проверку маршрутов на этапе компиляции.

// Типы генерируются автоматически в .expo/types/router.d.ts
// Теперь IDE подсказывает допустимые маршруты

import { Link } from 'expo-router';

// TypeScript знает все допустимые пути
О нас           // OK
Профиль       // OK
Нет       // ОШИБКА компиляции!

Для типизации параметров динамических маршрутов используйте дженерик useLocalSearchParams:

import { useLocalSearchParams } from 'expo-router';

// Строгая типизация параметров
const { id } = useLocalSearchParams<{ id: string }>();

// Для необязательных параметров
const { id, tab } = useLocalSearchParams<{
  id: string;
  tab?: string;
}>();

Типизированные маршруты работают и с хуком useRouter:

import { useRouter } from 'expo-router';

const router = useRouter();

// Автодополнение подскажет допустимые пути
router.push('/products/123');     // OK
router.push('/nonexistent');      // ОШИБКА компиляции!

// Типизация с динамическими параметрами
router.push({
  pathname: '/products/[id]',
  params: { id: '42' },
});

Скажу честно: после того как привыкаешь к типизированным маршрутам, работать без них уже не хочется. Опечатки в путях навигации — один из самых распространённых и трудноуловимых багов в мобильных приложениях, а типизация полностью устраняет их ещё на этапе разработки.

Защищённые маршруты и аутентификация

Реализация аутентификации — одна из тех задач, которые встречаются буквально в каждом втором приложении. Expo Router предлагает элегантный декларативный подход через Stack.Protected. Это современный способ разделения публичных и приватных экранов, который заменяет старые императивные проверки в каждом компоненте.

Важный момент: защищённые маршруты в Expo Router работают только на клиенте. Они не обеспечивают серверную безопасность — это исключительно UI-уровень, определяющий, какие экраны видит пользователь.

Stack.Protected — декларативный подход

Идея красиво простая: вместо проверки if (!user) redirect('/login') в каждом компоненте, вы объявляете, какие экраны защищены, прямо в layout-файле. А Expo Router сам решает, что показывать.

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

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

  if (isLoading) {
    return ;
  }

  return (
    
      {/* Публичные экраны — всегда доступны */}
      

      {/* Экраны аутентификации — только для неавторизованных */}
      
        
        
      

      {/* Защищённые экраны — только для авторизованных */}
      
        
        
      
    
  );
}

Создание контекста аутентификации

Для работы защищённых маршрутов нужен контекст, который предоставляет информацию о текущем пользователе. Вот полный пример (кстати, этот паттерн можно переиспользовать практически в любом приложении):

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

interface User {
  id: string;
  email: string;
  name: string;
}

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

const AuthContext = createContext(null);

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

  useEffect(() => {
    // Проверяем сохранённый токен при запуске приложения
    loadStoredAuth();
  }, []);

  async function loadStoredAuth() {
    try {
      const token = await SecureStore.getItemAsync('authToken');
      if (token) {
        const response = await fetch('https://api.example.com/auth/me', {
          headers: { Authorization: `Bearer ${token}` },
        });
        if (response.ok) {
          const userData = await response.json();
          setUser(userData);
        }
      }
    } catch (error) {
      console.error('Ошибка загрузки авторизации:', error);
    } finally {
      setIsLoading(false);
    }
  }

  async function signIn(email: string, password: string) {
    const response = await fetch('https://api.example.com/auth/login', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ email, password }),
    });

    if (!response.ok) {
      throw new Error('Неверные учётные данные');
    }

    const { token, user: userData } = await response.json();
    await SecureStore.setItemAsync('authToken', token);
    setUser(userData);
  }

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

  async function signUp(email: string, password: string, name: string) {
    const response = await fetch('https://api.example.com/auth/register', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ email, password, name }),
    });

    if (!response.ok) {
      throw new Error('Ошибка регистрации');
    }

    const { token, user: userData } = await response.json();
    await SecureStore.setItemAsync('authToken', token);
    setUser(userData);
  }

  return (
    
      {children}
    
  );
}

export function useAuth() {
  const context = useContext(AuthContext);
  if (!context) {
    throw new Error('useAuth должен использоваться внутри AuthProvider');
  }
  return context;
}

Провайдер оборачивает layout:

// app/_layout.tsx
import { Slot } from 'expo-router';
import { AuthProvider } from '../context/AuthContext';

export default function RootLayout() {
  return (
    
      
    
  );
}

// Навигатор в отдельном компоненте, чтобы иметь доступ к AuthContext
function RootNavigator() {
  const { user, isLoading } = useAuth();

  if (isLoading) {
    return ;
  }

  return (
    
      
        
      
      
        
        
      
    
  );
}

Deep linking: всё работает автоматически

Вот что мне по-настоящему нравится в файловой маршрутизации — deep linking из коробки. В React Navigation настройка deep linking требовала отдельного linking config с ручным маппингом URL на экраны. С Expo Router каждый файл автоматически получает свой URL, и deep linking просто работает.

Как это работает

Когда пользователь открывает ссылку myapp://products/42, Expo Router автоматически:

  1. Разбирает URL на сегменты: products и 42
  2. Находит файл app/products/[id].tsx
  3. Передаёт { id: '42' } как параметры маршрута
  4. Навигирует на нужный экран с правильным стеком навигации

Для настройки достаточно указать схему в app.json:

{
  "expo": {
    "scheme": "myapp"
  }
}

И всё! Приложение теперь реагирует на ссылки вида myapp://любой/путь. Для веба URL будут обычными HTTP-путями.

Универсальные ссылки (Universal Links / App Links)

Для production-приложений обычно нужны не кастомные схемы, а универсальные ссылки — те, которые открывают приложение по обычному HTTPS-URL. Expo Router поддерживает это через ассоциированные домены:

{
  "expo": {
    "scheme": "myapp",
    "ios": {
      "associatedDomains": ["applinks:myapp.example.com"]
    },
    "android": {
      "intentFilters": [
        {
          "action": "VIEW",
          "autoVerify": true,
          "data": [
            {
              "scheme": "https",
              "host": "myapp.example.com",
              "pathPrefix": "/"
            }
          ],
          "category": ["BROWSABLE", "DEFAULT"]
        }
      ]
    }
  }
}

Теперь ссылка https://myapp.example.com/products/42 откроет приложение прямо на экране товара. А если приложение не установлено — покажет веб-версию (если она настроена, конечно).

Native Tabs и SplitView API для планшетов

С ростом популярности планшетов и десктопных приложений через Mac Catalyst адаптивный дизайн навигации становится всё важнее. Expo Router предлагает пару инструментов для этого.

Native Tabs: нативная производительность

Мы уже касались Native Tabs выше, но давайте копнём чуть глубже. Главное отличие от обычных Tabs — рендеринг полностью на нативном уровне. Что это даёт:

  • Автоматическая обработка safe area insets — таб-бар правильно позиционируется на устройствах с вырезами, Dynamic Island и закруглёнными углами. Никакого дополнительного кода.
  • Нативные анимации переключения — переходы между вкладками используют платформенные анимации, а не JavaScript.
  • Синхронное обновление — экраны обновляются синхронно, что убирает мерцание белого фона при переключении вкладок.
  • Интеграция с системой — на iPad нативный таб-бар адаптируется под размер экрана, на macOS интегрируется с тулбаром.

SplitView: адаптивная навигация для больших экранов

SplitView — экспериментальный API, который создаёт двухпанельную навигацию в стиле iPad. Слева — список (например, чаты), справа — детальный контент (конкретный чат). На iPhone это автоматически превращается в стандартный стек.

Имейте в виду: на момент написания SplitView является экспериментальным и работает только на iOS. На остальных платформах он фолбечится на Slot navigator.

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

export default function SplitLayout() {
  return (
    
      
        {/* Левая панель — список */}
      
      
        {/* Правая панель — детали */}
      
    
  );
}

Файловая структура для SplitView:

app/
└── (split)/
    ├── _layout.tsx       # SplitView layout
    ├── index.tsx         # Sidebar (список)
    └── [id].tsx          # Detail (содержимое)

На iPad пользователь увидит обе панели одновременно: слева список, справа — контент выбранного элемента. На iPhone — обычный стековый переход. И всё это — без единой строчки платформозависимого кода.

Навигация между экранами

Expo Router даёт несколько способов навигации. Разберём каждый.

Компонент Link — декларативная навигация

Link — самый простой способ создать навигацию. Компонент рендерит нажимаемый элемент и выполняет переход при тапе:

import { Link } from 'expo-router';
import { View, Text } from 'react-native';

export default function HomeScreen() {
  return (
    
      {/* Простая текстовая ссылка */}
      О приложении

      {/* Ссылка с динамическим параметром */}
      Товар #42

      {/* Ссылка с объектом href — для сложных параметров */}
      
        Отзывы о товаре
      

      {/* Ссылка с заменой текущего экрана */}
      Выйти и авторизоваться

      {/* Ссылка внутри кастомного компонента */}
      
        
          Настройки
        
      
    
  );
}

Проп asChild особенно удобен — он позволяет обернуть любой компонент в навигационную ссылку, сохранив его внешний вид и поведение.

Хук useRouter — императивная навигация

Когда нужно перейти на другой экран программно (после отправки формы, по результату API-вызова), используйте хук useRouter:

import { useRouter } from 'expo-router';
import { View, Button, Alert } from 'react-native';

export default function LoginScreen() {
  const router = useRouter();

  async function handleLogin() {
    try {
      await signIn(email, password);

      // router.push — добавляет экран в стек (можно вернуться назад)
      router.push('/dashboard');

      // router.replace — заменяет текущий экран (нельзя вернуться)
      router.replace('/dashboard');

      // router.navigate — умная навигация, избегает дублей в стеке
      router.navigate('/dashboard');

    } catch (error) {
      Alert.alert('Ошибка', 'Неверные учётные данные');
    }
  }

  async function handleBack() {
    // router.back() — возврат на предыдущий экран
    router.back();

    // router.canGoBack() — проверка
    if (router.canGoBack()) {
      router.back();
    }

    // router.dismiss() — закрыть модальное окно
    router.dismiss();

    // router.dismissAll() — закрыть все модальные окна
    router.dismissAll();
  }

  return (
    
      

Разница между push, navigate и replace

Это частый источник путаницы, так что давайте разложим по полочкам:

  • router.push('/screen') — всегда добавляет новый экран в стек. Вызвали push('/screen') три раза — в стеке три копии. Используйте, когда точно хотите добавить новый экран.
  • router.navigate('/screen') — умная навигация. Если экран уже есть в стеке — вернётся к нему. Если нет — добавит новый. Это предпочтительный метод в большинстве случаев.
  • router.replace('/screen') — заменяет текущий экран новым. Предыдущий удаляется из стека. Идеально для сценария после авторизации — чтобы пользователь не мог вернуться на экран логина.

Навигация с параметрами

import { useRouter } from 'expo-router';

const router = useRouter();

// Простой путь со встроенным параметром
router.push('/products/42');

// Объект с pathname и params — для сложных случаев
router.push({
  pathname: '/products/[id]',
  params: {
    id: '42',
    source: 'home',
    showReviews: 'true',
  },
});

// Параметры доступны через useLocalSearchParams
// { id: '42', source: 'home', showReviews: 'true' }

Практический пример: приложение с аутентификацией

Теория — это хорошо, но давайте уже соберём всё вместе. Построим приложение с экранами авторизации, лентой постов и профилем пользователя.

Файловая структура проекта

app/
├── _layout.tsx              # Корневой layout с AuthProvider
├── (auth)/
│   ├── _layout.tsx          # Stack для экранов авторизации
│   ├── login.tsx            # Экран входа
│   └── register.tsx         # Экран регистрации
├── (tabs)/
│   ├── _layout.tsx          # Tabs/NativeTabs layout
│   ├── index.tsx            # Лента (главная)
│   ├── search.tsx           # Поиск
│   └── profile.tsx          # Профиль
├── post/
│   └── [id].tsx             # Детали поста (динамический маршрут)
└── settings.tsx             # Настройки (модальный экран)

context/
└── AuthContext.tsx           # Контекст авторизации

hooks/
└── useAuth.ts               # Хук для доступа к авторизации

Корневой layout с аутентификацией

// app/_layout.tsx
import { Stack } from 'expo-router';
import { StatusBar } from 'expo-status-bar';
import { AuthProvider, useAuth } from '../context/AuthContext';
import { View, ActivityIndicator, StyleSheet } from 'react-native';

export default function RootLayout() {
  return (
    
      
      
    
  );
}

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

  if (isLoading) {
    return (
      
        
      
    );
  }

  return (
    
      {/* Экраны авторизации — только для неавторизованных */}
      
        
      

      {/* Основные экраны — только для авторизованных */}
      
        
        
        
      
    
  );
}

const styles = StyleSheet.create({
  loading: {
    flex: 1,
    justifyContent: 'center',
    alignItems: 'center',
    backgroundColor: '#0f0f23',
  },
});

Layout экранов авторизации

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

export default function AuthLayout() {
  return (
    
      
      
    
  );
}

Экран входа

// app/(auth)/login.tsx
import { useState } from 'react';
import {
  View, Text, TextInput, Pressable,
  StyleSheet, Alert, KeyboardAvoidingView, Platform,
} from 'react-native';
import { Link, useRouter } from 'expo-router';
import { useAuth } from '../../context/AuthContext';

export default function LoginScreen() {
  const [email, setEmail] = useState('');
  const [password, setPassword] = useState('');
  const [isSubmitting, setIsSubmitting] = useState(false);
  const { signIn } = useAuth();
  const router = useRouter();

  async function handleSignIn() {
    if (!email || !password) {
      Alert.alert('Ошибка', 'Заполните все поля');
      return;
    }

    setIsSubmitting(true);
    try {
      await signIn(email, password);
      // После успешного входа Stack.Protected автоматически
      // переключит навигацию — ничего не нужно делать вручную!
    } catch (error) {
      Alert.alert('Ошибка входа', 'Неверный email или пароль');
    } finally {
      setIsSubmitting(false);
    }
  }

  return (
    
      
        Добро пожаловать
        Войдите в свой аккаунт

        

        

        
          
            {isSubmitting ? 'Входим...' : 'Войти'}
          
        

        
          Нет аккаунта? Зарегистрируйтесь
        
      
    
  );
}

const styles = StyleSheet.create({
  container: { flex: 1, backgroundColor: '#0f0f23' },
  content: { flex: 1, justifyContent: 'center', padding: 24 },
  title: { fontSize: 32, fontWeight: 'bold', color: '#fff', textAlign: 'center' },
  subtitle: { fontSize: 16, color: '#aaa', textAlign: 'center', marginTop: 8, marginBottom: 32 },
  input: {
    backgroundColor: '#1a1a2e',
    borderRadius: 12,
    padding: 16,
    fontSize: 16,
    color: '#fff',
    marginBottom: 16,
    borderWidth: 1,
    borderColor: '#333',
  },
  button: {
    backgroundColor: '#6C63FF',
    borderRadius: 12,
    padding: 16,
    alignItems: 'center',
    marginTop: 8,
  },
  buttonDisabled: { opacity: 0.6 },
  buttonText: { color: '#fff', fontSize: 18, fontWeight: '600' },
  link: { color: '#6C63FF', textAlign: 'center', marginTop: 24, fontSize: 15 },
});

Layout вкладок с Native Tabs

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

export default function TabsLayout() {
  return (
    
       (
            
          ),
        }}
      />
       (
            
          ),
        }}
      />
       (
            
          ),
        }}
      />
    
  );
}

Главный экран — лента с постами

// app/(tabs)/index.tsx
import { View, Text, FlatList, Pressable, StyleSheet } from 'react-native';
import { useRouter } from 'expo-router';

interface Post {
  id: string;
  title: string;
  preview: string;
  author: string;
  date: string;
}

const MOCK_POSTS: Post[] = [
  {
    id: '1',
    title: 'Expo SDK 55: что нового?',
    preview: 'Обзор ключевых изменений в новой версии...',
    author: 'Алексей',
    date: '2026-02-05',
  },
  {
    id: '2',
    title: 'React Native 0.83 и New Architecture',
    preview: 'Разбираемся с Fabric и TurboModules...',
    author: 'Мария',
    date: '2026-02-03',
  },
  {
    id: '3',
    title: 'Hermes V1: новый движок JavaScript',
    preview: 'Что изменилось в первой мажорной версии...',
    author: 'Дмитрий',
    date: '2026-01-28',
  },
];

export default function FeedScreen() {
  const router = useRouter();

  function renderPost({ item }: { item: Post }) {
    return (
       router.push(`/post/${item.id}`)}
      >
        {item.title}
        {item.preview}
        
          {item.author}
          {item.date}
        
      
    );
  }

  return (
     item.id}
      contentContainerStyle={styles.list}
      style={styles.container}
    />
  );
}

const styles = StyleSheet.create({
  container: { flex: 1, backgroundColor: '#0f0f23' },
  list: { padding: 16 },
  card: {
    backgroundColor: '#1a1a2e',
    borderRadius: 12,
    padding: 16,
    marginBottom: 12,
    borderWidth: 1,
    borderColor: '#2a2a3e',
  },
  cardTitle: { fontSize: 18, fontWeight: '600', color: '#fff' },
  cardPreview: { fontSize: 14, color: '#aaa', marginTop: 6 },
  cardMeta: { flexDirection: 'row', justifyContent: 'space-between', marginTop: 12 },
  cardAuthor: { fontSize: 13, color: '#6C63FF' },
  cardDate: { fontSize: 13, color: '#666' },
});

Экран поста с динамическим параметром

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

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

  // В реальном приложении тут был бы запрос к API
  // const { data: post } = useQuery(['post', id], () => fetchPost(id));

  return (
    
      
      Expo SDK 55: что нового?
      
        Алексей
        5 февраля 2026
      
      
        Expo SDK 55 — это большое обновление, которое приносит React Native 0.83.1,
        React 19.2, новый Hermes V1 и полный отказ от Legacy Architecture.
        В этой статье разберём все ключевые изменения подробно...
      
    
  );
}

const styles = StyleSheet.create({
  container: { flex: 1, backgroundColor: '#0f0f23', padding: 20 },
  title: { fontSize: 26, fontWeight: 'bold', color: '#fff', lineHeight: 34 },
  meta: { flexDirection: 'row', gap: 16, marginTop: 12, marginBottom: 20 },
  author: { fontSize: 14, color: '#6C63FF' },
  date: { fontSize: 14, color: '#666' },
  body: { fontSize: 16, color: '#ccc', lineHeight: 26 },
});

Экран профиля с кнопкой выхода

// app/(tabs)/profile.tsx
import { View, Text, Pressable, StyleSheet, Image } from 'react-native';
import { Link } from 'expo-router';
import { useAuth } from '../../context/AuthContext';

export default function ProfileScreen() {
  const { user, signOut } = useAuth();

  return (
    
      
        
          {user?.name?.charAt(0)?.toUpperCase() || '?'}
        
      
      {user?.name}
      {user?.email}

      
        
          Настройки
        
      

      
        Выйти из аккаунта
      
    
  );
}

const styles = StyleSheet.create({
  container: { flex: 1, backgroundColor: '#0f0f23', alignItems: 'center', paddingTop: 40 },
  avatar: {
    width: 80, height: 80, borderRadius: 40,
    backgroundColor: '#6C63FF', justifyContent: 'center', alignItems: 'center',
  },
  avatarText: { fontSize: 32, fontWeight: 'bold', color: '#fff' },
  name: { fontSize: 22, fontWeight: '600', color: '#fff', marginTop: 16 },
  email: { fontSize: 15, color: '#999', marginTop: 4 },
  menuItem: {
    width: '90%', padding: 16, backgroundColor: '#1a1a2e',
    borderRadius: 12, marginTop: 24,
  },
  menuText: { fontSize: 16, color: '#fff' },
  logoutButton: {
    width: '90%', padding: 16, backgroundColor: '#2a1a1a',
    borderRadius: 12, marginTop: 12, alignItems: 'center',
    borderWidth: 1, borderColor: '#ff4444',
  },
  logoutText: { fontSize: 16, color: '#ff4444', fontWeight: '500' },
});

Обратите внимание на ключевой момент: после signOut() не нужно никуда навигировать вручную. Stack.Protected в корневом layout сам обнаружит, что user стал null, и покажет экраны авторизации. То же самое после signIn() — навигация автоматически переключится на табы. Вот это и есть настоящий декларативный подход в действии.

Советы по производительности и лучшие практики

Expo Router из коробки оптимизирован неплохо, но есть ряд практик, которые помогут выжать из него максимум. Я собрал те, которые реально влияют на результат.

1. Ленивая загрузка тяжёлых экранов

По умолчанию Expo Router загружает все экраны при старте. Для тяжёлых экранов с большими зависимостями стоит использовать React.lazy:

// app/heavy-screen.tsx
import React, { Suspense } from 'react';
import { ActivityIndicator, View } from 'react-native';

const HeavyContent = React.lazy(() => import('../components/HeavyContent'));

export default function HeavyScreen() {
  return (
    
          
        
      }
    >
      
    
  );
}

2. Используйте router.navigate вместо router.push

Как мы уже обсуждали, router.navigate предотвращает накопление дубликатов в стеке. Это не только чище для UX, но и экономит память:

// Плохо — может создать дубликаты
router.push('/profile');
router.push('/profile');
router.push('/profile'); // В стеке 3 копии!

// Хорошо — умная навигация
router.navigate('/profile');
router.navigate('/profile');
router.navigate('/profile'); // В стеке по-прежнему 1 экран

3. Выносите логику из директории app/

Бизнес-логика и UI-компоненты должны жить вне app/. Файлы маршрутов — это по сути тонкие обёртки, точки входа и не более:

// app/products/[id].tsx — тонкая обёртка
import ProductDetailScreen from '../../screens/ProductDetailScreen';
export default ProductDetailScreen;

// screens/ProductDetailScreen.tsx — вся логика здесь
import { View, Text } from 'react-native';
import { useLocalSearchParams } from 'expo-router';
import { useProduct } from '../hooks/useProduct';

export default function ProductDetailScreen() {
  const { id } = useLocalSearchParams<{ id: string }>();
  const { data: product, isLoading } = useProduct(id);
  // ... вся логика экрана
}

Такой подход делает экраны проще для тестирования, переиспользования и рефакторинга.

4. Загружайте данные по фокусу, а не при монтировании

Экраны на неактивных вкладках всё равно остаются смонтированными. Используйте useFocusEffect, чтобы загружать данные только когда пользователь реально смотрит на экран:

import { useFocusEffect } from 'expo-router';
import { useCallback, useState } from 'react';

export default function SearchScreen() {
  const [results, setResults] = useState([]);

  useFocusEffect(
    useCallback(() => {
      // Вызывается при каждом фокусе экрана
      loadTrendingSearches();

      return () => {
        // Очистка при потере фокуса (опционально)
      };
    }, [])
  );

  // ...
}

5. Предзагрузка данных перед навигацией

Если используете TanStack Query, можно начать загрузку данных ещё до перехода на экран. Пользователь даже не заметит задержки:

import { useRouter } from 'expo-router';
import { useQueryClient } from '@tanstack/react-query';

export default function ProductListScreen() {
  const router = useRouter();
  const queryClient = useQueryClient();

  function handleProductPress(productId: string) {
    // Начинаем загрузку ДО перехода
    queryClient.prefetchQuery({
      queryKey: ['product', productId],
      queryFn: () => fetchProduct(productId),
    });

    // Переходим — данные уже летят в фоне
    router.push(`/products/${productId}`);
  }

  // ...
}

6. Правильная обработка модальных окон

Для закрытия модальных окон используйте router.dismiss(), а не router.back(). Это гарантирует корректное поведение на всех платформах:

// Открытие
router.push('/settings'); // settings настроен как modal в layout

// Закрытие
router.dismiss();        // Закрывает текущий модал
router.dismissAll();     // Закрывает все модалы

7. Синхронное обновление экранов

В SDK 55 экраны обновляются синхронно по умолчанию — контент появляется мгновенно, без мерцания белого фона. Если вдруг видите мерцание — проверьте, что не переопределили это поведение в конфигурации навигатора.

8. Типизируйте маршруты

Включите typedRoutes: true и используйте TypeScript. Серьёзно. Типизированные маршруты ловят ошибки навигации на этапе компиляции, а не когда тестировщик случайно нажмёт на нужную кнопку.

// tsconfig.json
{
  "extends": "expo/tsconfig.base",
  "compilerOptions": {
    "strict": true
  },
  "include": [
    "**/*.ts",
    "**/*.tsx",
    ".expo/types/**/*.ts",
    "expo-env.d.ts"
  ]
}

9. Группируйте маршруты осмысленно

Используйте группы (скобки) для логического разделения фич приложения:

app/
├── (auth)/           # Авторизация
├── (tabs)/           # Основная навигация с табами
├── (admin)/          # Панель администрирования
├── (onboarding)/     # Онбординг нового пользователя
└── (modals)/         # Модальные экраны

10. Не злоупотребляйте вложенностью

Глубоко вложенные навигаторы усложняют и навигацию, и deep linking. Старайтесь держать максимум 2-3 уровня. Если структура становится слишком глубокой — это сигнал пересмотреть архитектуру.

Заключение

Expo Router в 2026 году — зрелый, мощный инструмент, который реально упрощает навигацию в React Native. Файловая маршрутизация, типобезопасность, автоматический deep linking, декларативная аутентификация через Stack.Protected, нативные вкладки и адаптивный SplitView — всё это работает из коробки и работает стабильно.

Вот что стоит запомнить:

  • Expo Router построен поверх React Navigation — ваши знания React Navigation по-прежнему в деле
  • Файловая структура = навигационная структура. Файл в app/ = маршрут
  • Layout-файлы (_layout.tsx) определяют навигаторы: Stack, Tabs, Drawer, NativeTabs, SplitView
  • Типизированные маршруты (typedRoutes: true) — включайте всегда, это устраняет целый класс багов
  • Stack.Protected — декларативный способ аутентификации (но только клиентский)
  • Native Tabs обрабатывают safe area автоматически
  • SplitView — экспериментальный, только iOS
  • Предпочитайте router.navigate вместо router.push
  • Синхронное обновление экранов устраняет мерцание при переходах

Если начинаете новый проект на React Native — используйте Expo Router. Если есть существующий проект на React Navigation — подумайте о миграции. Файловая маршрутизация — это не просто тренд, а действительно более удобный подход к навигации, который уже стал стандартом в экосистеме Expo.

Об авторе Editorial Team

Our team of expert writers and editors.