
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.
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.
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 :
index.tsx et non page.tsx_layout.tsx (underscore) et non layout.tsxEn 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).
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>.
// 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>
}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>
)
}OK, on a beaucoup parlé des similitudes. Voilà les points où tu vas devoir changer tes réflexes.
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 :
flexDirection est column par défaut (et non row comme en CSS)gap (il faut utiliser marginBottom sur les enfants... ou gap si tu cibles Expo SDK 49+)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> |
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>
}Deux features qui fonctionnent très bien avec Expo Router et qui te serviront vite.
// 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")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.
Identique :
app/[id]useRouter(), router.push(), router.back()<Link href="...">(nom)/Change :
layout.tsx → _layout.tsxpage.tsx → index.tsx pour la racine<div> → <View>, <p> → <Text>, <button> → <Pressable>StyleSheet.create()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.