Expo Router v6: Guida Pratica alla Navigazione File-Based in React Native

Guida pratica a Expo Router v6 con Expo SDK 54: routing file-based, Native Tabs con Liquid Glass su iOS 26, route tipizzate e protette, modali, deep linking universale e pattern avanzati per React Native.

Introduzione: la navigazione è cambiata per sempre

Chi ha sviluppato app React Native negli ultimi anni lo sa fin troppo bene: la navigazione è sempre stata una spina nel fianco. Stack, tab, drawer, deep link, flussi di autenticazione — tutto richiedeva un mare di codice imperativo, spesso copia-incollato e pieno di insidie. Ecco, con l'arrivo di Expo Router v6, rilasciato insieme a Expo SDK 54 nell'estate 2025, le cose sono cambiate parecchio.

Il concetto di fondo è il routing basato sul file system — lo stesso approccio che ha reso Next.js così popolare nel web — portato direttamente nello sviluppo mobile. Ogni file nella cartella app/ diventa una rotta, ogni sottocartella definisce un layout, e la struttura delle directory è la vostra mappa di navigazione. Basta configurazioni manuali, basta boilerplate.

Ma Expo Router v6 non si ferma qui. Native Tabs con l'effetto Liquid Glass di iOS 26, route protette per l'autenticazione, tipizzazione statica delle rotte, modali migliorate, e persino un server middleware sperimentale. Devo ammettere che quando ho visto la lista delle novità per la prima volta, ho pensato fosse troppo bello per essere vero.

In questa guida vediamo tutto, dalla struttura di base ai pattern più avanzati, con codice reale e qualche consiglio che viene dall'esperienza sul campo. Che siate alle prime armi o sviluppatori navigati (gioco di parole voluto), qui dovreste trovare quello che vi serve.

Prerequisiti e setup iniziale

Prima di tutto, assicuriamoci di avere l'occorrente. Expo Router v6 richiede Expo SDK 54 o superiore, quindi lavorerete con React Native 0.81 e React 19.1. La Nuova Architettura è ormai lo standard — non si torna più indietro al vecchio Bridge.

Per creare un progetto nuovo con Expo Router già pronto all'uso:

npx create-expo-app@latest mia-app
cd mia-app
npx expo start

Se invece avete già un progetto e volete aggiornare:

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

Controllate che il vostro app.json abbia la configurazione giusta:

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

Un dettaglio importante: con SDK 55 (al momento in beta), la Nuova Architettura è diventata l'unica opzione — il flag newArchEnabled è stato proprio rimosso da app.json. Se state ancora sull'architettura legacy, beh, è decisamente ora di migrare.

Fondamenti del routing file-based

Il concetto alla base di Expo Router è tanto semplice quanto efficace: la struttura dei file definisce la struttura della navigazione. Ogni file dentro app/ che esporta un componente React diventa automaticamente una rotta.

Come funziona la mappatura file → rotta

Guardiamo un esempio concreto:

app/
├── _layout.tsx          # Layout radice
├── index.tsx            # Rotta: /
├── about.tsx            # Rotta: /about
├── settings/
│   ├── _layout.tsx      # Layout per /settings/*
│   ├── index.tsx        # Rotta: /settings
│   └── profile.tsx      # Rotta: /settings/profile
└── products/
    ├── _layout.tsx      # Layout per /products/*
    └── [id].tsx         # Rotta dinamica: /products/123

Ogni file con export default diventa una schermata. I file _layout.tsx (quelli col trattino basso) sono speciali — definiscono come le schermate figlie vengono disposte, ma non creano rotte proprie. E i file index.tsx sono la rotta predefinita della directory. Se avete lavorato con Next.js, vi sentirete subito a casa.

Il primo componente: una pagina semplice

// app/index.tsx
import { View, Text, StyleSheet } from 'react-native';
import { Link } from 'expo-router';

export default function HomeScreen() {
  return (
    <View style={styles.container}>
      <Text style={styles.title}>Benvenuto nella mia app!</Text>
      <Link href="/about" style={styles.link}>
        Vai alla pagina About
      </Link>
      <Link href="/products/42" style={styles.link}>
        Vedi prodotto #42
      </Link>
    </View>
  );
}

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

Rotte dinamiche con parametri

Le parentesi quadre nel nome del file indicano un segmento dinamico — in pratica l'equivalente dei parametri URL:

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

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

  return (
    <View style={{ flex: 1, justifyContent: 'center', alignItems: 'center' }}>
      <Text style={{ fontSize: 20 }}>Dettaglio prodotto: {id}</Text>
    </View>
  );
}

E poi ci sono le catch-all routes con [...slug].tsx, che catturano qualsiasi percorso non matchato da altre rotte. Comodissime per le pagine 404 personalizzate o per rotte con profondità variabile.

Layout e navigatori: il cuore dell'app

I file _layout.tsx sono davvero il motore di tutta la navigazione in Expo Router. Ogni directory può averne uno, e definisce come le schermate figlie vengono presentate — stack, tab, drawer, quel che volete.

Il layout radice

app/_layout.tsx è il punto di ingresso. Qui inizializzate font, splash screen, provider di contesto e il navigatore principale:

// app/_layout.tsx
import { Stack } from 'expo-router';
import { useFonts } from 'expo-font';
import * as SplashScreen from 'expo-splash-screen';
import { useEffect } from 'react';
import { ThemeProvider, DefaultTheme, DarkTheme } from '@react-navigation/native';
import { useColorScheme } from 'react-native';

SplashScreen.preventAutoHideAsync();

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

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

  if (!fontsLoaded) return null;

  return (
    <ThemeProvider value={colorScheme === 'dark' ? DarkTheme : DefaultTheme}>
      <Stack>
        <Stack.Screen name="(tabs)" options={{ headerShown: false }} />
        <Stack.Screen name="modal" options={{ presentation: 'modal' }} />
      </Stack>
    </ThemeProvider>
  );
}

Occhio al ThemeProvider — è particolarmente importante con le Native Tabs di Expo Router v6. Senza, potreste ritrovarvi con artefatti visivi brutti quando il Liquid Glass prova a fare il suo lavoro in dark mode. Ci sono passato.

Navigazione a tab

Ora, la navigazione a tab è probabilmente il pattern più diffuso nelle app mobile. Con Expo Router basta creare una cartella (tabs) con il proprio layout:

app/
├── _layout.tsx
└── (tabs)/
    ├── _layout.tsx      # Definisce i tab
    ├── index.tsx         # Tab Home
    ├── search.tsx        # Tab Ricerca
    └── profile.tsx       # Tab Profilo
// app/(tabs)/_layout.tsx
import { Tabs } from 'expo-router';
import { Ionicons } from '@expo/vector-icons';

export default function TabLayout() {
  return (
    <Tabs
      screenOptions={{
        tabBarActiveTintColor: '#007AFF',
        headerStyle: { backgroundColor: '#f8f9fa' },
      }}
    >
      <Tabs.Screen
        name="index"
        options={{
          title: 'Home',
          tabBarIcon: ({ color, size }) => (
            <Ionicons name="home" size={size} color={color} />
          ),
        }}
      />
      <Tabs.Screen
        name="search"
        options={{
          title: 'Ricerca',
          tabBarIcon: ({ color, size }) => (
            <Ionicons name="search" size={size} color={color} />
          ),
        }}
      />
      <Tabs.Screen
        name="profile"
        options={{
          title: 'Profilo',
          tabBarIcon: ({ color, size }) => (
            <Ionicons name="person" size={size} color={color} />
          ),
        }}
      />
    </Tabs>
  );
}

L'ordine in cui dichiarate gli Screen dentro Tabs determina l'ordine nella barra. Il file index.tsx è quello selezionato di default.

Native Tabs e Liquid Glass: l'effetto iOS 26

E qui viene il bello. Una delle novità più d'impatto di Expo Router v6 è il supporto per le Native Tabs: tab bar implementate con componenti nativi del sistema operativo, non i soliti componenti JavaScript. Su iOS 26 otterrete automaticamente l'effetto Liquid Glass — quella finitura trasparente e fluida che Apple ha sparso dappertutto sulla piattaforma.

Configurazione delle Native Tabs

Per usarle, importate il componente dal modulo sperimentale:

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

export default function TabLayout() {
  return (
    <Tabs>
      <Tabs.Screen
        name="index"
        options={{
          title: 'Home',
          tabBarIcon: ({ color }) => (
            <Ionicons name="home" color={color} size={24} />
          ),
        }}
      />
      <Tabs.Screen
        name="search"
        options={{
          title: 'Ricerca',
          tabBarIcon: ({ color }) => (
            <Ionicons name="search" color={color} size={24} />
          ),
        }}
      />
      <Tabs.Screen
        name="profile"
        options={{
          title: 'Profilo',
          tabBarIcon: ({ color }) => (
            <Ionicons name="person" color={color} size={24} />
          ),
        }}
      />
    </Tabs>
  );
}

Cosa vi serve: Expo SDK 54+, Expo Router v6+ e React Native Screens 4.16.0+. Per il Liquid Glass dovete compilare con Xcode 26.

Comportamenti nativi automatici

Le Native Tabs si portano dietro una serie di comportamenti che prima dovevate implementare a mano (e che puntualmente dimenticavate):

  • Scroll-to-top — premendo un tab già attivo, la lista torna su da sola
  • Pop-to-root — se l'utente è dentro uno stack e preme il tab, torna alla schermata radice
  • Long press su Android — mostra un popover col nome della schermata
  • Accessori iOS 26 — supporto per elementi accessori sulla tab bar, tipo il Mini Player di Apple Music

Su iOS 26 vedrete il Liquid Glass con i colori che si adattano allo sfondo. Su versioni precedenti otterrete il look classico, e su Android l'implementazione resta comunque nativa. Ecco il punto: un'unica API, comportamento nativo ottimale ovunque. Nella mia esperienza, è proprio questo tipo di astrazione che fa la differenza nel quotidiano.

Attenzione però: le Native Tabs sono ancora in alpha. L'API potrebbe cambiare, e per app già in produzione dovreste valutare se il progetto può reggere eventuali breaking change.

Route tipizzate: sicurezza a tempo di compilazione

Questa è una di quelle feature che una volta provata non si torna più indietro: la tipizzazione statica delle rotte. Il sistema genera automaticamente i tipi TypeScript per tutte le rotte dell'app, e voi potete navigare solo verso percorsi che esistono davvero.

Abilitare le route tipizzate

Nel vostro app.json:

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

Dopo aver avviato il dev server, Expo genera un file .expo/types/router.d.ts con tutti i tipi. Da lì in poi, TypeScript vi urla addosso se provate a navigare verso una rotta che non esiste:

import { router } from 'expo-router';

// ✅ Corretto — la rotta esiste
router.push('/products/42');

// ❌ Errore TypeScript — rotta inesistente
router.push('/pagina-che-non-esiste');

// ✅ Corretto — con parametri tipizzati
router.push({
  pathname: '/products/[id]',
  params: { id: '42' },
});

Questo diventa preziosissimo durante il refactoring. Rinominate un file? Errori di compilazione istantanei in tutti i punti che lo referenziavano. Niente più link rotti che saltano fuori in produzione un venerdì sera.

Hook di navigazione tipizzati

Anche gli hook beneficiano della tipizzazione. useLocalSearchParams si può tipizzare per avere parametri con il tipo giusto:

// app/users/[userId]/posts/[postId].tsx
import { useLocalSearchParams } from 'expo-router';

type Params = {
  userId: string;
  postId: string;
};

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

  // userId e postId sono garantiti come stringhe
  return (/* ... */);
}

Navigazione programmatica e hook essenziali

Oltre al componente <Link> per la navigazione dichiarativa, avete a disposizione l'oggetto router e diversi hook per navigare da codice.

L'oggetto router

import { router } from 'expo-router';

// Aggiungere una schermata allo stack
router.push('/products/42');

// Sostituire la schermata corrente (non aggiunta allo storico)
router.replace('/login');

// Tornare indietro
router.back();

// Navigare in modo "smart" — se la rotta è già nello stack, torna indietro
router.navigate('/home');

// Navigare con parametri
router.push({
  pathname: '/search',
  params: { query: 'react native', category: 'tutorial' },
});

// Chiudere tutte le schermate e ricominciare
router.dismissAll();

Hook principali

import {
  useRouter,
  useLocalSearchParams,
  useGlobalSearchParams,
  useSegments,
  usePathname,
  useNavigation,
} from 'expo-router';

export default function MyScreen() {
  // Accesso all'oggetto router
  const router = useRouter();

  // Parametri della rotta corrente
  const { id } = useLocalSearchParams();

  // Parametri globali (disponibili ovunque)
  const globalParams = useGlobalSearchParams();

  // Segmenti del percorso corrente — utile per navigazione condizionale
  const segments = useSegments();

  // Percorso completo corrente
  const pathname = usePathname();

  // Accesso diretto al navigatore React Navigation (per casi avanzati)
  const navigation = useNavigation();

  return (/* ... */);
}

Un consiglio: useSegments() è utilissimo per capire in quale tab vi trovate. Se avete tab (feed) e (search), il primo segmento vi dice dove siete, così potete navigare mantenendo il contesto.

Autenticazione e route protette

La gestione dell'autenticazione. Quante volte ci siamo scontrati con redirect manuali, guard improvvisati e race condition assortite? Expo Router v6 (a partire da SDK 53) introduce le route protette — un modo dichiarativo per controllare l'accesso alle schermate.

Architettura del flusso di autenticazione

La struttura consigliata separa le schermate pubbliche da quelle protette in modo netto:

app/
├── _layout.tsx          # Root layout con SessionProvider
├── sign-in.tsx          # Schermata pubblica
├── sign-up.tsx          # Schermata pubblica
└── (app)/
    ├── _layout.tsx      # Layout protetto
    ├── (tabs)/
    │   ├── _layout.tsx
    │   ├── index.tsx
    │   └── profile.tsx
    └── settings.tsx
ctx.tsx                  # Context di sessione

Il context di sessione

// ctx.tsx
import { createContext, useContext, useState, useCallback } from 'react';
import * as SecureStore from 'expo-secure-store';
import { Platform } from 'react-native';

interface Session {
  token: string;
  userId: string;
}

interface SessionContextType {
  session: Session | null;
  isLoading: boolean;
  signIn: (email: string, password: string) => Promise<void>;
  signOut: () => void;
}

const SessionContext = createContext<SessionContextType>({
  session: null,
  isLoading: true,
  signIn: async () => {},
  signOut: () => {},
});

export function useSession() {
  return useContext(SessionContext);
}

async function saveToken(token: string) {
  if (Platform.OS === 'web') {
    localStorage.setItem('session-token', token);
  } else {
    await SecureStore.setItemAsync('session-token', token);
  }
}

async function deleteToken() {
  if (Platform.OS === 'web') {
    localStorage.removeItem('session-token');
  } else {
    await SecureStore.deleteItemAsync('session-token');
  }
}

export function SessionProvider({ children }: { children: React.ReactNode }) {
  const [session, setSession] = useState<Session | null>(null);
  const [isLoading, setIsLoading] = useState(false);

  const signIn = useCallback(async (email: string, password: string) => {
    setIsLoading(true);
    try {
      const res = await fetch('https://api.example.com/auth/login', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ email, password }),
      });
      const data = await res.json();
      await saveToken(data.token);
      setSession({ token: data.token, userId: data.userId });
    } finally {
      setIsLoading(false);
    }
  }, []);

  const signOut = useCallback(() => {
    deleteToken();
    setSession(null);
  }, []);

  return (
    <SessionContext.Provider value={{ session, isLoading, signIn, signOut }}>
      {children}
    </SessionContext.Provider>
  );
}

Route protette con Stack.Protected

Il layout radice usa Stack.Protected per decidere cosa mostrare in base allo stato di autenticazione:

// app/_layout.tsx
import { Stack } from 'expo-router';
import { SessionProvider, useSession } from '../ctx';

function RootNavigator() {
  const { session } = useSession();

  return (
    <Stack screenOptions={{ headerShown: false }}>
      {/* Schermate visibili solo quando NON autenticati */}
      <Stack.Protected guard={!session}>
        <Stack.Screen name="sign-in" />
        <Stack.Screen name="sign-up" />
      </Stack.Protected>

      {/* Schermate visibili solo quando autenticati */}
      <Stack.Protected guard={!!session}>
        <Stack.Screen name="(app)" />
      </Stack.Protected>
    </Stack>
  );
}

export default function RootLayout() {
  return (
    <SessionProvider>
      <RootNavigator />
    </SessionProvider>
  );
}

Quando il guard è false, la schermata diventa inaccessibile. Se qualcuno prova ad accedere a una rotta protetta (anche tramite deep link), viene reindirizzato automaticamente. E quando lo stato di autenticazione cambia, il layout si aggiorna da solo e le entry delle schermate protette vengono rimosse dallo storico. Finalmente una soluzione pulita.

Route protette annidate per il controllo dei ruoli

Ma il bello è che potete annidare i guard per gestire ruoli diversi:

<Stack>
  <Stack.Protected guard={isLoggedIn}>
    <Stack.Screen name="dashboard" />
    <Stack.Protected guard={isAdmin}>
      <Stack.Screen name="admin-panel" />
    </Stack.Protected>
    <Stack.Protected guard={isPremium}>
      <Stack.Screen name="premium-features" />
    </Stack.Protected>
  </Stack.Protected>
</Stack>

In questo caso, admin-panel è accessibile solo agli utenti autenticati che sono anche admin, mentre premium-features richiede autenticazione più abbonamento premium. Semplice, leggibile, dichiarativo.

Modali: overlay eleganti

Le modali sono uno di quei pattern che usi continuamente nelle app mobile — form veloci, conferme, dettagli extra, tutto ciò che non dovrebbe rimpiazzare completamente la schermata sotto.

Configurare una modale

Per crearne una, basta definire la schermata nel layout radice con presentation: 'modal':

app/
├── _layout.tsx
├── (tabs)/
│   └── ...
└── create-post.tsx      # Questa sarà una modale
// app/_layout.tsx
import { Stack } from 'expo-router';

export default function RootLayout() {
  return (
    <Stack>
      <Stack.Screen name="(tabs)" options={{ headerShown: false }} />
      <Stack.Screen
        name="create-post"
        options={{
          presentation: 'modal',
          title: 'Nuovo Post',
          headerRight: () => (
            <Pressable onPress={() => router.back()}>
              <Text>Chiudi</Text>
            </Pressable>
          ),
        }}
      />
    </Stack>
  );
}

Dettaglio carino: in Expo Router v6, le modali su web emulano il comportamento di iPad e iPhone, invece di essere una pagina a schermo intero. Per chi sviluppa app universali, è un bel passo avanti.

Modali con passaggio dati

// Aprire la modale con parametri
router.push({
  pathname: '/create-post',
  params: { category: 'tutorial', draft: 'true' },
});

// app/create-post.tsx
import { useLocalSearchParams, router } from 'expo-router';

export default function CreatePostModal() {
  const { category, draft } = useLocalSearchParams();

  const handleSave = async () => {
    // ... salva il post
    router.back(); // Chiude la modale
  };

  return (
    <View style={{ flex: 1, padding: 20 }}>
      <Text>Categoria: {category}</Text>
      {/* Form per il post... */}
    </View>
  );
}

Deep linking universale

Uno dei vantaggi più concreti del routing file-based è che il deep linking funziona da solo. Ogni pagina ha un URL che corrisponde alla sua posizione nel file system, e quell'URL funziona sia come link web che come deep link nativo. Zero configurazione aggiuntiva per il routing.

Configurazione per le piattaforme native

Per i deep link nativi, serve lo schema dell'app nel vostro app.json:

{
  "expo": {
    "scheme": "mia-app",
    "ios": {
      "associatedDomains": ["applinks:mio-dominio.com"]
    },
    "android": {
      "intentFilters": [
        {
          "action": "VIEW",
          "autoVerify": true,
          "data": [{ "scheme": "https", "host": "mio-dominio.com" }],
          "category": ["BROWSABLE", "DEFAULT"]
        }
      ]
    }
  }
}

Con questa configurazione, mia-app://products/42 o https://mio-dominio.com/products/42 apriranno la schermata del prodotto 42. Non c'è codice di routing da scrivere — Expo Router mappa l'URL alla pagina giusta.

E grazie alla Continuous Native Generation (CNG) di Expo, la configurazione dei deep link viene gestita automaticamente durante il build. Niente più mani dentro AndroidManifest.xml o Info.plist. Onestamente, solo per questo vale la pena usare Expo.

Integrazione con Expo Head

Expo Head abilita feature native avanzate come Quick Notes, Handoff e Siri Context — e serve solo configurazione, niente codice particolare:

// app/products/[id].tsx
import Head from 'expo-router/head';

export default function ProductScreen() {
  const { id } = useLocalSearchParams();

  return (
    <>
      <Head>
        <title>Prodotto {id} | MiaApp</title>
        <meta name="description" content={`Dettagli del prodotto ${id}`} />
      </Head>
      {/* Contenuto della schermata */}
    </>
  );
}

Gruppi di rotte e pattern avanzati

I gruppi di rotte — le directory col nome tra parentesi — sono un meccanismo potentissimo per organizzare la navigazione senza toccare la struttura degli URL. All'inizio possono sembrare un po' astratti, ma una volta capiti non ne farete più a meno.

Condivisione di schermate tra tab

Uno dei pattern più utili in assoluto: condividere una schermata tra più tab. Ad esempio, un profilo utente raggiungibile sia dal feed che dalla ricerca:

app/
└── (tabs)/
    ├── _layout.tsx
    ├── (feed,search)/        # Rotta condivisa tra i due gruppi
    │   └── user/[userId].tsx
    ├── (feed)/
    │   └── index.tsx
    └── (search)/
        └── index.tsx

Con questa struttura, user/[userId].tsx è accessibile da entrambi i tab, e l'utente rimane nel tab corrente. Niente salti di contesto.

Nascondere rotte dalla tab bar

A volte vi serve una rotta dentro un gruppo tab che non deve apparire come tab. Basta href: null:

<Tabs>
  <Tabs.Screen name="index" />
  <Tabs.Screen name="search" />
  <Tabs.Screen
    name="notifications"
    options={{ href: null }} // Non mostrare nella tab bar
  />
</Tabs>

Layout senza navigatore con Slot

Non tutti i layout hanno bisogno di un navigatore vero e proprio. Con Slot potete creare wrapper che aggiungono header, footer o provider attorno alle schermate figlie:

// app/(app)/_layout.tsx
import { Slot } from 'expo-router';
import { CustomHeader } from '../../components/CustomHeader';

export default function AppLayout() {
  return (
    <View style={{ flex: 1 }}>
      <CustomHeader />
      <Slot />
    </View>
  );
}

Tab personalizzati con expo-router/ui

Se i tab predefiniti non vi bastano (e succede più spesso di quanto pensiate), expo-router/ui offre componenti headless per costruire interfacce tab completamente custom:

import { Tabs, TabList, TabTrigger, TabSlot } from 'expo-router/ui';
import { View, Text, Pressable, StyleSheet } from 'react-native';

export default function CustomTabLayout() {
  return (
    <Tabs>
      <View style={styles.container}>
        <TabSlot />
        <TabList style={styles.tabBar}>
          <TabTrigger name="home" href="/" style={styles.tab}>
            <Text>🏠 Home</Text>
          </TabTrigger>
          <TabTrigger name="explore" href="/explore" style={styles.tab}>
            <Text>🔍 Esplora</Text>
          </TabTrigger>
          <TabTrigger name="profile" href="/profile" style={styles.tab}>
            <Text>👤 Profilo</Text>
          </TabTrigger>
        </TabList>
      </View>
    </Tabs>
  );
}

const styles = StyleSheet.create({
  container: { flex: 1 },
  tabBar: {
    flexDirection: 'row',
    justifyContent: 'space-around',
    paddingVertical: 12,
    backgroundColor: '#ffffff',
    borderTopWidth: 1,
    borderTopColor: '#e0e0e0',
  },
  tab: { alignItems: 'center', padding: 8 },
});

Controllo totale sull'aspetto, ma tutta la logica di routing rimane gestita da Expo Router. Il meglio dei due mondi.

Async Routes e ottimizzazione del bundle

Le Async Routes (bundle splitting) sono una di quelle cose che sembrano un dettaglio tecnico ma che in pratica fanno una differenza enorme. Ogni rotta viene caricata solo quando serve, non tutta insieme all'avvio.

// app.json
{
  "expo": {
    "experiments": {
      "typedRoutes": true
    },
    "web": {
      "bundler": "metro",
      "output": "server"
    }
  }
}

In sviluppo il vantaggio è immediato: il bundling diventa incrementale, viene compilata solo la rotta che state guardando, e i tempi di refresh calano drasticamente. In produzione il lazy loading riduce la memoria iniziale e migliora il tempo di avvio.

C'è poi un vantaggio che spesso viene sottovalutato: gli errori restano isolati alla singola rotta. Se una pagina ha un bug, il resto dell'app continua a funzionare tranquillamente. Per il debugging e per le migrazioni incrementali è una manna dal cielo.

Novità di Expo SDK 55: il futuro della navigazione

L'SDK 55, al momento in beta, porta ancora più roba interessante:

  • Apple Zoom Transition — transizioni shared element interattive su iOS, abilitate di default. L'effetto è davvero fluido.
  • Stack.Toolbar API — una UIToolbar nativa per iOS, con azioni e menu in fondo allo schermo. Un'alternativa ai tab per certi scenari.
  • SplitView sperimentale — layout a due colonne su iPad e tablet. Era ora, la community lo chiedeva da un pezzo.
  • Colors API — colori dinamici che si adattano al Material 3 su Android e ai colori di sistema su iOS. Piccola cosa, ma fa molto.
  • Gestione automatica della Safe Area — i layout con native-tabs gestiscono gli insets automaticamente su entrambe le piattaforme.
  • Hermes V1 — il nuovo motore JavaScript con performance migliori e supporto più completo per le feature moderne di JS.

La direzione è chiara: Expo Router punta a diventare IL framework di navigazione per React Native, con un'integrazione nativa sempre più stretta.

Best practice e consigli pratici

Ok, dopo aver visto tutto il possibile, chiudiamo con un po' di best practice — alcune dalla documentazione, altre dalla trincea.

Struttura delle cartelle consigliata

app/
├── _layout.tsx              # Root: provider, tema, font
├── (auth)/                  # Gruppo autenticazione
│   ├── sign-in.tsx
│   └── sign-up.tsx
├── (app)/                   # Gruppo app protetta
│   ├── _layout.tsx          # Tab navigator
│   ├── (tabs)/
│   │   ├── _layout.tsx
│   │   ├── index.tsx
│   │   ├── search.tsx
│   │   └── profile.tsx
│   └── settings/
│       ├── _layout.tsx
│       └── index.tsx
├── modal.tsx                # Modale globale
└── +not-found.tsx           # Pagina 404
components/                  # Componenti riutilizzabili
hooks/                       # Hook personalizzati
constants/                   # Costanti
services/                    # Chiamate API

Regole d'oro

  1. Abilitate sempre le route tipizzate — costa zero e il ritorno in termini di sicurezza e refactoring è enorme. Sul serio, fatelo.
  2. Usate i gruppi di rotte per separare le aree logiche(auth), (app), (onboarding) rendono il codice chiaro e la navigazione prevedibile.
  3. Preferite router.navigate() a router.push() dove possibile — evita duplicati nello stack.
  4. Mantenete i layout snelli — la logica di business va nei componenti delle schermate, non nei layout. È tentante infilare roba nei layout, ma resistete.
  5. Usate +not-found.tsx — una pagina 404 fatta bene migliora l'esperienza utente, specialmente coi deep link.
  6. Testate i deep link presto — non aspettate l'ultimo giorno. npx expo start --deep-linking è vostro amico durante lo sviluppo.
  7. Occhio al ThemeProvider — soprattutto con Native Tabs e Liquid Glass. Senza il provider del tema corretto, gli artefatti visivi arrivano puntuali.

Conclusione

Expo Router v6 è, senza mezzi termini, un punto di svolta per la navigazione in React Native. Il passaggio dal routing imperativo a quello file-based non è solo un cambio di sintassi — è un modo diverso di pensare la struttura dell'app, che semplifica lo sviluppo e riduce gli errori in maniera tangibile.

Native Tabs col Liquid Glass, route protette, tipizzazione statica, deep linking automatico, bundle splitting — c'è veramente tutto quello che serve per un sistema di navigazione completo.

Se state partendo con un progetto nuovo nel 2026, Expo Router è la scelta ovvia. E se avete un progetto esistente con React Navigation, la migrazione è graduale — potete iniziare una sezione alla volta senza stravolgere tutto.

Il mio consiglio? Partite semplici. Un layout radice, qualche tab, un paio di schermate. Poi aggiungete complessità man mano che l'app cresce. La bellezza del routing file-based è che la struttura si documenta da sola: basta aprire la cartella app/ e chiunque nel team capisce immediatamente come è fatta la navigazione. E in un mondo dove la documentazione è sempre l'ultima cosa che si aggiorna... beh, non è poco.

Sull'Autore Editorial Team

Our team of expert writers and editors.