Expo Router Trong React Native 2026: Stack, Tabs, Drawer, Protected Routes Và Deep Linking

Hướng dẫn Expo Router v7 trong React Native 2026 — từ file-based routing, Stack, Tabs, Drawer đến Protected Routes, deep linking và typed routes. Code ví dụ thực tế cho mọi pattern.

Tại sao Expo Router là tương lai của navigation trong React Native

Nếu bạn đã đi cùng series này — từ Expo SDK 55, tối ưu hiệu năng, quản lý state, debug đến testing — thì có lẽ bạn cũng nhận ra mình chưa đề cập đến một chủ đề cực kỳ quan trọng: navigation.

Nói thật, điều hướng là xương sống của mọi app mobile. App đẹp mấy mà navigation rối thì người dùng cũng bực bội và xoá app nhanh lắm.

Và năm 2026, câu trả lời cho navigation trong React Native đã rõ ràng hơn bao giờ hết: Expo Router.

Nếu bạn từng dùng React Navigation truyền thống — khai báo từng Stack.Navigator, Tab.Navigator bằng tay, config navigation container, xử lý deep linking riêng — thì Expo Router sẽ thay đổi hoàn toàn cách bạn nghĩ về routing. Thay vì viết hàng trăm dòng config, bạn chỉ cần tạo file. File nằm trong thư mục app/ → tự động trở thành một route. Đơn giản vậy thôi.

Expo SDK 55 đi kèm Expo Router v7 — phiên bản mạnh mẽ nhất từ trước đến nay. Những tính năng đáng chú ý nhất gồm: Stack API mới cho phép kiểm soát native header ngay trong component, Native Tabs dùng Material Design 3 trên Android, Stack.Protected để xử lý authentication gọn gàng, Apple Zoom Transition mặc định trên iOS, và còn nhiều hơn nữa.

Bài viết này sẽ đưa bạn từ con số 0 đến thành thạo Expo Router — mỗi phần đều kèm code thực tế để bạn copy về dùng ngay. Nào, bắt đầu thôi.

File-based routing: Nguyên lý cốt lõi của Expo Router

Ý tưởng cốt lõi thực ra cực kỳ đơn giản: cấu trúc thư mục = cấu trúc navigation. Mỗi file trong thư mục app/ tự động trở thành một màn hình, và mỗi thư mục con trở thành một nhóm route lồng nhau.

Ví dụ:

app/
├── _layout.tsx        → Root layout (navigator gốc)
├── index.tsx          → Trang chủ (/)
├── about.tsx          → Trang giới thiệu (/about)
├── settings/
│   ├── _layout.tsx    → Layout cho nhóm settings
│   ├── index.tsx      → /settings
│   └── profile.tsx    → /settings/profile
└── [id].tsx           → Dynamic route (/:id)

Cấu trúc trên sẽ tự động tạo ra các route: /, /about, /settings, /settings/profile, và /:id. Không cần khai báo navigator, không cần config — Expo Router đọc cấu trúc thư mục và tự sinh ra navigation tree. Lần đầu mình thấy cách này hoạt động, thú thật là khá "wow".

File đặc biệt: _layout.tsx

File _layout.tsx là trái tim của mỗi nhóm route. Nó quyết định loại navigator (Stack, Tabs, Drawer) và các cài đặt chung cho tất cả màn hình trong thư mục đó.

// app/_layout.tsx — Root layout
import { Stack } from "expo-router";

export default function RootLayout() {
  return (
    <Stack>
      <Stack.Screen name="index" options={{ title: "Trang Chủ" }} />
      <Stack.Screen name="about" options={{ title: "Giới Thiệu" }} />
      <Stack.Screen name="settings" options={{ headerShown: false }} />
    </Stack>
  );
}

Quy tắc quan trọng: File _layout.tsx ở thư mục gốc app/root layout — nơi bạn đặt logic khởi tạo app như load font, splash screen, context providers. Mỗi thư mục con cũng có thể có _layout.tsx riêng để định nghĩa navigator lồng nhau.

File index.tsx và quy ước đặt tên

Một số quy ước đặt tên bạn cần nắm:

  • index.tsx — Route mặc định của thư mục (giống index.html trên web)
  • [param].tsx — Dynamic route, param được truyền qua useLocalSearchParams()
  • [...rest].tsx — Catch-all route, khớp với mọi đường dẫn
  • +not-found.tsx — Trang 404 custom
  • (group)/ — Route group: nhóm route mà không tạo segment trong URL

Stack Navigation: API mới trong Expo Router v7

Stack navigation là pattern cơ bản nhất — mỗi màn hình mới được "đẩy" lên trên cùng, người dùng nhấn back để quay lại. Expo Router v7 mang đến một Stack API hoàn toàn mới, cho phép kiểm soát native header ngay bên trong component thay vì phải dùng options prop ở layout file.

Stack API khai báo bằng component

Cách cũ dùng options trông như thế này:

// Cách cũ — dùng options prop
<Stack.Screen
  name="product"
  options={{
    title: "Sản Phẩm",
    headerRight: () => <CartIcon />,
  }}
/>

Còn với v7, bạn khai báo trực tiếp trong component của màn hình luôn:

// app/product.tsx — Cách mới với Stack API v7
import { Stack } from "expo-router";
import { View, Text } from "react-native";

export default function ProductScreen() {
  return (
    <View style={{ flex: 1, padding: 16 }}>
      {/* Khai báo header ngay trong component */}
      <Stack.Screen>
        <Stack.Screen.Title>Sản Phẩm</Stack.Screen.Title>
        <Stack.Screen.HeaderRight>
          <CartIcon />
        </Stack.Screen.HeaderRight>
      </Stack.Screen>

      <Text>Chi tiết sản phẩm ở đây</Text>
    </View>
  );
}

Cá nhân mình thấy cách này tiện hơn nhiều — header logic nằm chung với screen logic, không bị phân tán ở layout file xa xôi. Và quan trọng là nó vẫn render native header (UINavigationBar trên iOS, Toolbar trên Android), nên hiệu năng hoàn toàn tương đương.

Điều hướng giữa các màn hình

Expo Router cung cấp hai cách điều hướng chính:

// Cách 1: Dùng component Link (khai báo — declarative)
import { Link } from "expo-router";

export default function HomeScreen() {
  return (
    <View>
      <Link href="/about">
        <Text>Đi tới trang Giới Thiệu</Text>
      </Link>

      {/* Dynamic route */}
      <Link href="/product/123">
        <Text>Xem sản phẩm #123</Text>
      </Link>

      {/* Replace thay vì push */}
      <Link href="/login" replace>
        <Text>Đăng nhập</Text>
      </Link>
    </View>
  );
}

// Cách 2: Dùng router object (mệnh lệnh — imperative)
import { useRouter } from "expo-router";

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

  const handlePurchase = async () => {
    await processPayment();
    router.push("/order-confirmation");
  };

  const handleCancel = () => {
    router.back(); // Quay lại màn hình trước
  };

  const handleLogout = () => {
    router.replace("/login"); // Replace, không thể back lại
  };

  return (
    <View>
      <Button title="Mua ngay" onPress={handlePurchase} />
      <Button title="Huỷ" onPress={handleCancel} />
    </View>
  );
}

Mẹo nhỏ: Dùng Link cho navigation tĩnh (menu, sidebar). Dùng router.push() / router.replace() khi cần điều hướng sau một hành động bất đồng bộ như API call hay xử lý form.

Apple Zoom Transition — mặc định trên iOS

Từ Expo Router v7, Apple Zoom Transition được bật mặc định trên iOS. Hiệu ứng này giống cách Apple Maps hay App Store chuyển từ danh sách sang chi tiết — màn hình mới "zoom" ra từ phần tử bạn vừa nhấn. Bạn không cần cấu hình gì cả, nó hoạt động out of the box.

Nói thật, lần đầu thấy transition này chạy smooth trên thiết bị thật, mình khá ấn tượng.

Tab Navigation: Bottom Tabs và Native Tabs

Hầu hết mọi app đều cần thanh tab ở dưới cùng — đây gần như là pattern bắt buộc. Expo Router hỗ trợ hai loại tab: JavaScript Tabs (component Tabs truyền thống) và Native Tabs mới sử dụng tab bar native của hệ điều hành.

JavaScript Tabs — Cách thiết lập cơ bản

Tạo cấu trúc thư mục như sau:

app/
├── _layout.tsx
└── (tabs)/
    ├── _layout.tsx      → Tabs layout
    ├── index.tsx         → Tab "Trang chủ"
    ├── explore.tsx       → Tab "Khám phá"
    └── profile.tsx       → Tab "Cá nhân"

File layout cho tabs:

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

export default function TabsLayout() {
  return (
    <Tabs
      screenOptions={{
        tabBarActiveTintColor: "#007AFF",
        tabBarInactiveTintColor: "#8E8E93",
        tabBarStyle: {
          backgroundColor: "#FFFFFF",
          borderTopWidth: 0.5,
          borderTopColor: "#E5E5EA",
        },
      }}
    >
      <Tabs.Screen
        name="index"
        options={{
          title: "Trang chủ",
          tabBarIcon: ({ color, size }) => (
            <Ionicons name="home" size={size} color={color} />
          ),
        }}
      />
      <Tabs.Screen
        name="explore"
        options={{
          title: "Khám phá",
          tabBarIcon: ({ color, size }) => (
            <Ionicons name="compass" size={size} color={color} />
          ),
        }}
      />
      <Tabs.Screen
        name="profile"
        options={{
          title: "Cá nhân",
          tabBarIcon: ({ color, size }) => (
            <Ionicons name="person" size={size} color={color} />
          ),
        }}
      />
    </Tabs>
  );
}

Native Tabs — Tab bar native của hệ điều hành

Native Tabs là tính năng mới đáng chú ý trong Expo Router v7. Thay vì render tab bar bằng JavaScript, nó dùng trực tiếp UITabBarController trên iOS và BottomNavigationView (Material Design 3) trên Android. Kết quả là animation mượt hơn, responsive tốt hơn, và tự động theo theme hệ thống.

// app/(tabs)/_layout.tsx — Dùng Native Tabs
import { NativeTabs } from "expo-router";

export default function TabsLayout() {
  return (
    <NativeTabs>
      <NativeTabs.Screen name="index" />
      <NativeTabs.Screen name="explore" />
      <NativeTabs.Screen name="profile" />
    </NativeTabs>
  );
}

Trên Android, Native Tabs tự động sử dụng Material Design 3 dynamic colors — tab bar sẽ điều chỉnh màu theo wallpaper và theme người dùng. Không cần config gì thêm.

Lưu ý: Native Tabs vẫn đang được hoàn thiện trên tất cả platform. Nếu bạn cần tuỳ biến cao (badge, custom tab bar), hãy dùng JavaScript Tabs. Còn nếu ưu tiên cảm giác native và hiệu năng, chọn NativeTabs.

Drawer Navigation: Menu trượt từ cạnh

Drawer navigation — cái menu trượt ra từ cạnh trái (hoặc phải) — thường thấy trong app có nhiều section như admin panel, app tin tức, hoặc app thương mại điện tử. Không phải app nào cũng cần drawer, nhưng khi cần thì nó rất tiện.

Cài đặt dependencies

Drawer không đi kèm mặc định với Expo Router, bạn cần cài thêm:

npx expo install @react-navigation/drawer react-native-gesture-handler react-native-reanimated

Cấu trúc thư mục và code

app/
├── _layout.tsx
└── (drawer)/
    ├── _layout.tsx      → Drawer layout
    ├── index.tsx         → Trang chủ
    ├── orders.tsx        → Đơn hàng
    └── settings.tsx      → Cài đặt
// app/(drawer)/_layout.tsx
import { Drawer } from "expo-router/drawer";
import { GestureHandlerRootView } from "react-native-gesture-handler";

export default function DrawerLayout() {
  return (
    <GestureHandlerRootView style={{ flex: 1 }}>
      <Drawer
        screenOptions={{
          drawerActiveTintColor: "#007AFF",
          drawerLabelStyle: { fontSize: 16 },
        }}
      >
        <Drawer.Screen
          name="index"
          options={{
            drawerLabel: "Trang chủ",
            title: "Trang Chủ",
          }}
        />
        <Drawer.Screen
          name="orders"
          options={{
            drawerLabel: "Đơn hàng",
            title: "Đơn Hàng Của Bạn",
          }}
        />
        <Drawer.Screen
          name="settings"
          options={{
            drawerLabel: "Cài đặt",
            title: "Cài Đặt",
          }}
        />
      </Drawer>
    </GestureHandlerRootView>
  );
}

Để mở drawer bằng code (ví dụ khi nhấn nút hamburger):

import { useNavigation, DrawerActions } from "@react-navigation/native";

export default function HomeScreen() {
  const navigation = useNavigation();

  return (
    <View>
      <Button
        title="Mở Menu"
        onPress={() => navigation.dispatch(DrawerActions.openDrawer())}
      />
    </View>
  );
}

Nested Navigation: Kết hợp Stack, Tabs và Drawer

Trong thực tế, hầu hết app không chỉ dùng một loại navigator. Pattern rất phổ biến là: Stack gốc → Tabs → Stack lồng bên trong mỗi tab. Nghe phức tạp, nhưng Expo Router xử lý việc này khá tự nhiên nhờ cấu trúc thư mục.

Cấu trúc thực tế cho app thương mại điện tử

app/
├── _layout.tsx               → Root Stack
├── (tabs)/
│   ├── _layout.tsx           → Bottom Tabs
│   ├── home/
│   │   ├── _layout.tsx       → Stack cho tab Home
│   │   ├── index.tsx         → Danh sách sản phẩm
│   │   └── [productId].tsx   → Chi tiết sản phẩm
│   ├── cart/
│   │   ├── _layout.tsx       → Stack cho tab Giỏ hàng
│   │   ├── index.tsx         → Giỏ hàng
│   │   └── checkout.tsx      → Thanh toán
│   └── profile/
│       ├── _layout.tsx       → Stack cho tab Cá nhân
│       ├── index.tsx         → Thông tin cá nhân
│       └── edit.tsx          → Chỉnh sửa profile
├── modal.tsx                 → Modal toàn cục
└── +not-found.tsx            → Trang 404
// app/_layout.tsx — Root layout
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" }}
      />
    </Stack>
  );
}

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

export default function TabsLayout() {
  return (
    <Tabs>
      <Tabs.Screen
        name="home"
        options={{
          title: "Trang chủ",
          headerShown: false,
          tabBarIcon: ({ color }) => (
            <Ionicons name="home" size={24} color={color} />
          ),
        }}
      />
      <Tabs.Screen
        name="cart"
        options={{
          title: "Giỏ hàng",
          headerShown: false,
          tabBarIcon: ({ color }) => (
            <Ionicons name="cart" size={24} color={color} />
          ),
        }}
      />
      <Tabs.Screen
        name="profile"
        options={{
          title: "Cá nhân",
          headerShown: false,
          tabBarIcon: ({ color }) => (
            <Ionicons name="person" size={24} color={color} />
          ),
        }}
      />
    </Tabs>
  );
}

// app/(tabs)/home/_layout.tsx — Stack lồng trong tab Home
import { Stack } from "expo-router";

export default function HomeStack() {
  return (
    <Stack>
      <Stack.Screen name="index" options={{ title: "Sản Phẩm" }} />
      <Stack.Screen name="[productId]" options={{ title: "Chi Tiết" }} />
    </Stack>
  );
}

Cẩn thận nhé: Khi lồng Stack bên trong Tabs, nhớ set headerShown: false ở tab screen để tránh bị hai thanh header chồng nhau. Đây là lỗi phổ biến nhất mà mình thấy developer mới gặp — và mình cũng từng bị vài lần trước khi thành phản xạ.

Dynamic Routes: Xử lý tham số động

Dynamic routes cho phép bạn tạo một file duy nhất để xử lý nhiều URL khác nhau — ví dụ: /product/1, /product/2, /product/abc đều dùng chung file [productId].tsx.

// app/(tabs)/home/[productId].tsx
import { useLocalSearchParams } from "expo-router";
import { View, Text, ActivityIndicator } from "react-native";
import { useQuery } from "@tanstack/react-query";

export default function ProductDetailScreen() {
  // Lấy tham số từ URL
  const { productId } = useLocalSearchParams<{ productId: string }>();

  const { data: product, isLoading } = useQuery({
    queryKey: ["product", productId],
    queryFn: () => fetchProduct(productId),
  });

  if (isLoading) {
    return <ActivityIndicator size="large" />;
  }

  return (
    <View style={{ flex: 1, padding: 16 }}>
      <Text style={{ fontSize: 24, fontWeight: "bold" }}>
        {product?.name}
      </Text>
      <Text style={{ fontSize: 18, color: "#666", marginTop: 8 }}>
        {product?.price.toLocaleString("vi-VN")}₫
      </Text>
      <Text style={{ marginTop: 16 }}>{product?.description}</Text>
    </View>
  );
}

Catch-all routes

Nếu cần khớp với đường dẫn có nhiều segment (ví dụ: /category/electronics/phones), dùng catch-all route:

// app/category/[...slug].tsx
import { useLocalSearchParams } from "expo-router";

export default function CategoryScreen() {
  // slug = ["electronics", "phones"]
  const { slug } = useLocalSearchParams<{ slug: string[] }>();

  return (
    <View>
      <Text>Danh mục: {slug?.join(" > ")}</Text>
    </View>
  );
}

Protected Routes: Xử lý authentication với Stack.Protected

Đây là một trong những tính năng mình thích nhất ở Expo Router — và cũng là thứ trước đây developer phải tự viết bằng tay rất nhiều boilerplate code.

Trước Stack.Protected, cách phổ biến là dùng redirect hoặc conditional rendering trong layout. Vấn đề? Redirect có thể bị bypass bởi deep link, và logic auth bị rải rác khắp nơi — rất khó maintain.

Cách cũ — Redirect (vẫn hoạt động nhưng không recommended)

// Cách cũ — dùng Redirect component
import { Redirect } from "expo-router";
import { useAuth } from "@/hooks/useAuth";

export default function ProtectedScreen() {
  const { isAuthenticated } = useAuth();

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

  return <View>{/* Nội dung protected */}</View>;
}

Cách mới — Stack.Protected (recommended)

Stack.Protected cho phép bạn khai báo trực tiếp trong layout: màn hình nào cần auth, màn hình nào public. Khi guard={false}, người dùng không thể truy cập được các màn hình đó — kể cả qua deep link. Gọn gàng và an toàn hơn hẳn.

// app/_layout.tsx — Protected Routes
import { Stack } from "expo-router";
import { useAuth } from "@/hooks/useAuth";

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

  return (
    <Stack>
      {/* Màn hình public — luôn truy cập được */}
      <Stack.Screen name="login" options={{ headerShown: false }} />
      <Stack.Screen name="register" options={{ headerShown: false }} />

      {/* Màn hình protected — chỉ truy cập khi đã đăng nhập */}
      <Stack.Protected guard={isAuthenticated}>
        <Stack.Screen name="(tabs)" options={{ headerShown: false }} />
        <Stack.Screen name="modal" options={{ presentation: "modal" }} />
      </Stack.Protected>
    </Stack>
  );
}

Khi isAuthenticatedfalse: nếu người dùng đang ở màn hình protected, Router sẽ tự động chuyển họ sang màn hình available tiếp theo (ở đây là login). Nếu họ cố truy cập qua deep link, navigation sẽ thất bại im lặng — không crash, không lỗi. Khá elegant.

Role-based access control

Stack.Protected không chỉ dành cho authentication — bạn hoàn toàn có thể dùng nó cho phân quyền theo role:

// app/_layout.tsx — Role-based routing
import { Stack } from "expo-router";
import { useAuth } from "@/hooks/useAuth";

export default function RootLayout() {
  const { isAuthenticated, user } = useAuth();
  const isAdmin = user?.role === "admin";

  return (
    <Stack>
      <Stack.Screen name="login" />

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

      {/* Chỉ admin mới truy cập được */}
      <Stack.Protected guard={isAuthenticated && isAdmin}>
        <Stack.Screen name="admin" options={{ title: "Quản Trị" }} />
      </Stack.Protected>
    </Stack>
  );
}

Clean hơn rất nhiều so với việc check role trong từng screen component, phải không?

Deep Linking: Mặc định, không cần cấu hình

Đây là lợi thế lớn của Expo Router so với React Navigation truyền thống: mọi màn hình đều có URL và deep linkable mặc định. Bạn không cần viết linking.ts config hay khai báo path patterns — nó "just works".

Custom URL Scheme

Để app mở được từ link custom (ví dụ: myapp://product/123), thêm scheme vào app.json:

{
  "expo": {
    "scheme": "myapp",
    "web": {
      "bundler": "metro"
    }
  }
}

Thế là xong. Bất kỳ link nào có dạng myapp://product/123 sẽ tự động mở app và navigate tới app/(tabs)/home/[productId].tsx với productId = "123".

Universal Links (iOS) và App Links (Android)

Cho production app, bạn nên dùng Universal Links thay vì custom scheme. Universal Links dùng domain HTTPS thật (ví dụ: https://myapp.com/product/123) và có nhiều ưu điểm:

  • Nếu app chưa cài → mở trang web hoặc chuyển hướng tới App Store
  • Nếu app đã cài → mở thẳng app ở đúng màn hình
  • An toàn hơn custom scheme (không app nào khác có thể "hijack" domain của bạn)

Cấu hình trong app.json:

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

Sau đó, bạn cần host file apple-app-site-association (iOS) và assetlinks.json (Android) trên server. Nếu dùng EAS Hosting thì EAS sẽ hỗ trợ tự động phần này.

Typed Routes: An toàn kiểu dữ liệu khi navigate

Expo Router hỗ trợ typed routes — TypeScript sẽ báo lỗi ngay nếu bạn navigate tới một route không tồn tại hoặc thiếu parameter. Ai đã từng debug lỗi typo trong route name sẽ hiểu giá trị của tính năng này.

Bật Typed Routes

Thêm vào app.json:

{
  "expo": {
    "experiments": {
      "typedRoutes": true
    }
  }
}

Sau đó chạy npx expo start — Expo sẽ tự động generate file type definition dựa trên cấu trúc thư mục. Bây giờ thì:

import { Link } from "expo-router";

// TypeScript báo lỗi nếu route không tồn tại
<Link href="/produc/123">Sai</Link>  // Lỗi: route không tồn tại
<Link href="/product/123">Đúng</Link> // OK

// useRouter cũng được type-check
const router = useRouter();
router.push("/settings/profil"); // Lỗi: route không tồn tại

Typed routes đặc biệt hữu ích khi refactor — đổi tên file hoặc di chuyển route, TypeScript sẽ báo lỗi ở mọi nơi đang reference route cũ. Tiết kiệm khá nhiều thời gian debug.

Route Groups và Modal: Tổ chức code sạch sẽ

Route Groups — nhóm route không tạo URL segment

Dấu ngoặc tròn () tạo route group — nhóm file mà không ảnh hưởng tới URL:

app/
├── (auth)/
│   ├── _layout.tsx    → Layout cho nhóm auth
│   ├── login.tsx      → URL: /login (KHÔNG phải /auth/login)
│   └── register.tsx   → URL: /register
└── (main)/
    ├── _layout.tsx    → Layout cho nhóm main
    └── index.tsx      → URL: /

Route groups rất tiện để tách layout cho các nhóm màn hình khác nhau mà không làm thay đổi URL. Mình hay dùng cách này để tách flow auth khỏi flow chính của app.

Modal — Màn hình hiển thị dạng popup

Để hiển thị một màn hình dạng modal (trượt lên từ dưới, có thể dismiss bằng gesture):

// 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",
          title: "Thông Tin",
        }}
      />
      <Stack.Screen
        name="filter"
        options={{
          presentation: "formSheet",   // iOS 26+: Liquid Glass style
          sheetGrabberVisible: true,
          sheetCornerRadius: 20,
        }}
      />
    </Stack>
  );
}

Với iOS 26+, form sheet mặc định sẽ dùng Liquid Glass design language mới của Apple — không cần thêm code gì.

Xử lý lỗi 404 và Splash Screen

Trang 404 tuỳ chỉnh

Tạo file +not-found.tsx để xử lý khi người dùng truy cập route không tồn tại:

// 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: "Không tìm thấy" }} />
      <View style={styles.container}>
        <Text style={styles.title}>Trang này không tồn tại</Text>
        <Link href="/" style={styles.link}>
          <Text>Quay về trang chủ</Text>
        </Link>
      </View>
    </>
  );
}

const styles = StyleSheet.create({
  container: { flex: 1, justifyContent: "center", alignItems: "center" },
  title: { fontSize: 20, fontWeight: "bold" },
  link: { marginTop: 16, color: "#007AFF" },
});

Splash Screen kết hợp với navigation

Khi app khởi động, bạn thường cần load font, kiểm tra authentication token, fetch initial data — tất cả trước khi hiển thị màn hình đầu tiên. Đây là cách kết hợp expo-splash-screen với root layout:

// app/_layout.tsx
import { useEffect } from "react";
import { Stack } from "expo-router";
import * as SplashScreen from "expo-splash-screen";
import { useFonts } from "expo-font";
import { useAuth } from "@/hooks/useAuth";

// Giữ splash screen cho đến khi sẵn sàng
SplashScreen.preventAutoHideAsync();

export default function RootLayout() {
  const [fontsLoaded] = useFonts({
    "Inter-Regular": require("@/assets/fonts/Inter-Regular.ttf"),
    "Inter-Bold": require("@/assets/fonts/Inter-Bold.ttf"),
  });

  const { isLoading: authLoading, isAuthenticated } = useAuth();

  useEffect(() => {
    if (fontsLoaded && !authLoading) {
      SplashScreen.hideAsync();
    }
  }, [fontsLoaded, authLoading]);

  if (!fontsLoaded || authLoading) {
    return null; // Splash screen vẫn hiển thị
  }

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

Migrate từ React Navigation sang Expo Router

Nếu bạn đang có dự án dùng React Navigation truyền thống và muốn chuyển sang Expo Router, đây là checklist mình tổng hợp lại:

  1. Cài Expo Router: npx expo install expo-router react-native-safe-area-context react-native-screens expo-linking expo-constants expo-status-bar
  2. Tạo thư mục app/: Di chuyển screens vào đúng cấu trúc thư mục
  3. Xóa NavigationContainer: Expo Router tự quản lý container
  4. Chuyển navigator config sang _layout.tsx: Mỗi navigator trở thành một _layout.tsx trong thư mục tương ứng
  5. Thay navigation.navigate() bằng router.push(): Hoặc dùng Link component
  6. Cập nhật entry point: Trong package.json, set "main": "expo-router/entry"

Tin vui: Expo Router được xây dựng trên React Navigation — nên tất cả API React Navigation (options, listeners, screenOptions...) vẫn hoạt động. Bạn không mất gì khi migrate, chỉ thêm được file-based routing và deep linking tự động.

Các lỗi phổ biến và cách khắc phục

Sau nhiều dự án dùng Expo Router, đây là những lỗi mình thấy hay gặp nhất (và cũng từng mắc phải):

1. Hai thanh header chồng nhau

Triệu chứng: Màn hình có hai header stacked — một từ parent layout, một từ child layout.

Khắc phục: Set headerShown: false ở parent khi child layout đã có header riêng:

// Parent: app/(tabs)/_layout.tsx
<Tabs.Screen name="home" options={{ headerShown: false }} />

// Child: app/(tabs)/home/_layout.tsx — sẽ có header riêng
<Stack>
  <Stack.Screen name="index" options={{ title: "Trang Chủ" }} />
</Stack>

2. Route không mong muốn xuất hiện trong tabs

Triệu chứng: Bạn thêm file mới vào thư mục (tabs)/ và nó tự động thành một tab mới (không mong muốn).

Khắc phục: Khai báo rõ ràng tabs bạn muốn trong layout, hoặc dùng href: null để ẩn:

<Tabs.Screen
  name="hidden-screen"
  options={{ href: null }}  // Ẩn khỏi tab bar
/>

3. Deep link không hoạt động trên thiết bị thật

Triệu chứng: Deep link chạy ngon trên emulator nhưng fail trên thiết bị thật.

Khắc phục: Đảm bảo bạn đã chạy npx expo prebuild sau khi thêm scheme vào app.json. Với Universal Links, kiểm tra lại file apple-app-site-association đã được host đúng HTTPS và có MIME type application/json.

4. Metro cache gây lỗi route

Triệu chứng: Route mới không được nhận diện, hoặc route đã xóa vẫn "sống" dai dẳng.

Khắc phục: Xóa cache Metro và restart — cách giải quyết kinh điển:

npx expo start --clear

FAQ — Câu hỏi thường gặp

Expo Router và React Navigation khác nhau như thế nào?

Expo Router được xây dựng trên nền React Navigation — không phải thay thế. Nó thêm lớp file-based routing phía trên, giúp bạn không cần khai báo navigator thủ công. Tất cả API của React Navigation vẫn hoạt động bình thường. Nếu bắt đầu dự án mới với Expo thì dùng Expo Router. Nếu đang có dự án React Navigation, bạn có thể migrate dần dần — không cần làm một lần hết.

Có thể dùng Expo Router trong dự án bare React Native không?

Expo Router yêu cầu Expo framework. Tuy nhiên, từ SDK 55 trở đi, bạn có thể dùng expo-brownfield để tích hợp Expo vào dự án bare có sẵn mà không cần chuyển đổi toàn bộ. Với dự án bare hoàn toàn không dùng Expo thì tiếp tục dùng React Navigation trực tiếp.

Stack.Protected có hoạt động với deep link không?

Có — và đây chính là điểm mạnh nhất của nó. Khi guard={false}, người dùng không thể truy cập màn hình protected bằng bất kỳ cách nào — deep link, back navigation, hay nhập URL trực tiếp đều không được. Navigation sẽ thất bại im lặng mà không crash app.

Expo Router có hỗ trợ server-side rendering không?

Có — từ v7 (SDK 55), SSR được hỗ trợ dưới dạng experimental qua expo-server. Bạn có thể deploy lên EAS Hosting, Express, Vercel, Netlify, Cloudflare Workers hoặc Bun. Tuy nhiên, SSR chủ yếu hữu ích cho web — với native mobile thì bạn không cần lo phần này.

Làm thế nào để test navigation trong unit test?

Mock expo-router trong file jest.setup.ts. Mock useRouter trả về các hàm jest.fn() cho push, replace, back. Với E2E test bằng Maestro, bạn test trực tiếp luồng navigation trên app thật — hiệu quả hơn nhiều so với unit test cho phần navigation.

Về Tác Giả Editorial Team

Our team of expert writers and editors.