Expo Router pour les devs Next.js : le routing mobile que tu connais déjà

De Next.js à Expo Router : tu connais déjà la moitié
Tu bosses avec Next.js depuis un moment. L'App Router, les layouts, le file-based routing — tout ça, c'est devenu une seconde nature. Et maintenant tu veux te mettre au mobile. React Native t'intéresse, mais tu redoutes de repartir de zéro. J'ai travaillé sur de nombreux projets Next.js et quand je me lançais sur Expo, je me suis rendu compte que je connaissais déjà la moitié du travail.
Expo Router est un système de routing pour React Native qui s'est directement inspiré du Next.js App Router. Même philosophie, même structure de fichiers, même API de navigation. Dans cet article, on va passer en revue ce qui est identique, ce qui change légèrement, et les quelques points de friction à anticiper.
Le point de départ : créer un projet Expo Router
Avant tout, initialise un projet pour suivre les exemples :
npx create-expo-app@latest mon-app --template tabs
cd mon-app
npx expo startOuvre le dossier app/ — tu vas immédiatement reconnaître quelque chose.
1. La structure de fichiers : presque identique
C'est la première chose qui frappe. Expo Router utilise exactement le même principe de file-based routing que Next.js.
| Route souhaitée | Next.js App Router | Expo Router |
|---|---|---|
/ (accueil) | app/page.tsx | app/index.tsx |
/about | app/about/page.tsx | app/about.tsx |
/blog/[id] | app/blog/[id]/page.tsx | app/blog/[id].tsx |
| Layout global | app/layout.tsx | app/(root)/_layout.tsx |
Les différences à noter :
- La page racine s'appelle
index.tsxet nonpage.tsx - Les layouts s'appellent
_layout.tsx(underscore) et nonlayout.tsx - Les fichiers de route n'ont pas besoin d'être dans un sous-dossier
2. Les layouts : même concept, syntaxe légèrement différente
En Next.js, ton layout.tsx ressemble à ça :
// app/layout.tsx — Next.js
export default function RootLayout({
children,
}: {
children: React.ReactNode
}) {
return (
<html lang="fr">
<body>
<Header />
{children}
<Footer />
</body>
</html>
)
}En Expo Router, le _layout.tsx joue le même rôle de conteneur parent — mais la navigation native remplace le <html> :
// app/_layout.tsx — Expo Router
import { Stack } from "expo-router"
export default function RootLayout() {
return (
<Stack>
<Stack.Screen name="index" options={{ title: "Accueil" }} />
<Stack.Screen name="about" options={{ title: "À propos" }} />
</Stack>
)
}Stack est le navigateur le plus basique : il empile les écrans les uns sur les autres, comme une navigation mobile classique avec un bouton "retour". Tu peux le remplacer par Tabs pour une navigation à onglets (comme dans Instagram ou Twitter).
3. La navigation : useRouter() est quasi copié-collé
C'est là que la ressemblance devient presque troublante. Compare :
Next.js
import Link from "next/link"
import { useRouter } from "next/navigation"
export default function Page() {
const router = useRouter()
return (
<>
{/* Navigation déclarative */}
<Link href="/about">À propos</Link>
{/* Navigation programmatique */}
<button onClick={() => router.push("/blog/42")}>
Voir l'article
</button>
<button onClick={() => router.back()}>Retour</button>
</>
)
}Expo Router
import { Link, useRouter } from "expo-router"
import { Pressable, Text } from "react-native"
export default function Screen() {
const router = useRouter()
return (
<>
{/* Navigation déclarative */}
<Link href="/about">À propos</Link>
{/* Navigation programmatique */}
<Pressable onPress={() => router.push("/blog/42")}>
<Text>Voir l'article</Text>
</Pressable>
<Pressable onPress={() => router.back()}>
<Text>Retour</Text>
</Pressable>
</>
)
}useRouter(), router.push(), router.back(), router.replace() — tout ça fonctionne exactement pareil. La seule différence visible : <button> devient <Pressable> et <p> devient <Text>.
Les routes dynamiques fonctionnent pareil aussi
// app/blog/[id].tsx
import { useLocalSearchParams } from "expo-router"
export default function BlogPost() {
const { id } = useLocalSearchParams()
// Équivalent de useParams() ou params.id en Next.js
return <Text>Article #{id}</Text>
}4. Les layouts imbriqués : groupes de routes
Expo Router supporte aussi les groupes de routes avec les parenthèses, exactement comme Next.js :
app/
├── (auth)/
│ ├── _layout.tsx ← layout pour les pages auth
│ ├── login.tsx
│ └── register.tsx
├── (app)/
│ ├── _layout.tsx ← layout pour l'app principale (tabs)
│ ├── index.tsx
│ └── profile.tsx
└── _layout.tsx ← layout racine
Le layout (app)/_layout.tsx peut utiliser une navigation à onglets :
// app/(app)/_layout.tsx
import { Tabs } from "expo-router"
export default function AppLayout() {
return (
<Tabs>
<Tabs.Screen name="index" options={{ title: "Accueil" }} />
<Tabs.Screen name="profile" options={{ title: "Profil" }} />
</Tabs>
)
}5. Ce qui change vraiment
OK, on a beaucoup parlé des similitudes. Voilà les points où tu vas devoir changer tes réflexes.
Adieu le CSS, bonjour StyleSheet
C'est sans doute le plus grand changement. Il n'y a pas de CSS en React Native. Tout se fait avec StyleSheet.create() :
import { StyleSheet, View, Text } from "react-native"
export default function Card() {
return (
<View style={styles.card}>
<Text style={styles.title}>Mon titre</Text>
</View>
)
}
const styles = StyleSheet.create({
card: {
padding: 16,
backgroundColor: "#ffffff",
borderRadius: 8,
// Pas de "box-shadow" CSS — on utilise :
shadowColor: "#000",
shadowOffset: { width: 0, height: 2 },
shadowOpacity: 0.1,
shadowRadius: 4,
elevation: 3, // Android uniquement
},
title: {
fontSize: 18,
fontWeight: "600",
color: "#111",
},
})Flexbox fonctionne, mais avec quelques différences :
flexDirectionestcolumnpar défaut (et nonrowcomme en CSS)- Pas de
gap(il faut utilisermarginBottomsur les enfants... ougapsi tu cibles Expo SDK 49+)
Les primitives HTML n'existent pas
Tu n'as pas de <div>, <p>, <span>, <button>, <img>. À la place :
| HTML | React Native |
|---|---|
<div> | <View> |
<p>, <span>, <h1> | <Text> |
<button> | <Pressable> ou <TouchableOpacity> |
<img> | <Image> |
<input> | <TextInput> |
<ul><li> | <FlatList> |
Pas de SSR
Expo Router est 100% client-side. Pas de getServerSideProps, pas de Server Components, pas de fetch() au moment du rendu serveur. Tout se fait côté client, souvent avec useEffect ou des librairies comme React Query ou SWR.
// Fetching de données en Expo Router
import { useEffect, useState } from "react"
export default function Screen() {
const [data, setData] = useState(null)
useEffect(() => {
fetch("https://api.example.com/data")
.then((r) => r.json())
.then(setData)
}, [])
return <Text>{data?.title}</Text>
}6. Bonus : les modals et les deep links
Deux features qui fonctionnent très bien avec Expo Router et qui te serviront vite.
Les modals natifs
// app/_layout.tsx
<Stack>
<Stack.Screen name="index" />
<Stack.Screen
name="modal"
options={{ presentation: "modal" }} // ← magie native
/>
</Stack>// N'importe où dans l'app
router.push("/modal")Les deep links
Expo Router gère les deep links automatiquement. Si ton app s'appelle monapp, le lien monapp://blog/42 ouvrira directement app/blog/[id].tsx avec id = 42. Zéro configuration supplémentaire.
Récap : ce que tu retiens en 30 secondes
Identique :
- File-based routing dans le dossier
app/ - Routes dynamiques avec
[id] useRouter(),router.push(),router.back()- Composant
<Link href="..."> - Groupes de routes avec
(nom)/ - Layouts imbriqués
Change :
layout.tsx→_layout.tsxpage.tsx→index.tsxpour la racine<div>→<View>,<p>→<Text>,<button>→<Pressable>- CSS →
StyleSheet.create() - Pas de SSR
Pour aller plus loin
- Documentation officielle Expo Router
- Expo Router vs React Navigation — si tu viens de l'ancien système
- NativeWind — si tu veux retrouver Tailwind CSS en mobile
Cet article a été écrit dans une démarche "learn in public" : je découvre Expo Router en venant de Next.js, et je documente ce que j'apprends. Si tu repères une erreur ou une imprécision, tu peux me contacter via les réseaux sociaux.
