TL;DR
- File-based routing = your folder structure is your route config. No
routes.js, no giant switch statement. - The core conventions are nearly universal:
index→/,[param]→ dynamic segment,[...rest]→ catch-all,(group)→ organize without changing the URL,_layout/layout→ shared UI. - Next.js (web) and Expo Router (mobile) use almost the same mental model, so you can learn it once and use it everywhere.
- The gotchas: route groups, layout nesting, and where auth guards live. We'll cover all three with code.
The old way: routes as config
If you've been writing React for a while, you've written something like this:
// App.jsx — the "before" picture
import { BrowserRouter, Routes, Route } from "react-router-dom";
export default function App() {
return (
<BrowserRouter>
<Routes>
<Route path="/" element={<Home />} />
<Route path="/blog" element={<BlogIndex />} />
<Route path="/blog/:slug" element={<BlogPost />} />
<Route path="/settings" element={<SettingsLayout />}>
<Route path="profile" element={<Profile />} />
<Route path="billing" element={<Billing />} />
</Route>
<Route path="*" element={<NotFound />} />
</Routes>
</BrowserRouter>
);
}
It works. But now you have two sources of truth: the file where the component lives, and the config that says where it's mounted. Rename a file, forget the config, and you get a broken route that nobody notices until prod.
File-based routing deletes the second source of truth.
The new way: the folder is the router
Here's the same app with file-based routing (Next.js App Router):
app/
├── layout.tsx → wraps everything
├── page.tsx → /
├── blog/
│ ├── page.tsx → /blog
│ └── [slug]/
│ └── page.tsx → /blog/:slug
├── settings/
│ ├── layout.tsx → shared settings shell
│ ├── profile/page.tsx → /settings/profile
│ └── billing/page.tsx → /settings/billing
└── not-found.tsx → 404
No config file. You look at the tree, you know the URLs. New dev joins the team? They ls app/ and they understand the whole app's navigation in ten seconds.
The conventions (they're basically universal)
Once you learn these five patterns, you can read almost any file-based router.
| Pattern | Meaning | Example URL |
|---|---|---|
index / page
|
The root of a folder | /blog |
[id] |
Dynamic segment | /blog/hello-world |
[...slug] |
Catch-all (1+ segments) | /docs/a/b/c |
(group) |
Folder for organization, not in URL |
(marketing)/about → /about
|
layout / _layout
|
Shared UI wrapping children | nav bars, tabs, auth shells |
Dynamic segments
The bracket name becomes a param you can read:
// app/blog/[slug]/page.tsx (Next.js 15+)
export default async function BlogPost({
params,
}: {
params: Promise<{ slug: string }>;
}) {
const { slug } = await params;
const post = await getPost(slug);
return (
<article>
<h1>{post.title}</h1>
<div dangerouslySetInnerHTML={{ __html: post.html }} />
</article>
);
}
Heads up: in Next.js 15,
paramsbecame a Promise in server components. If you're upgrading from 14 and suddenly gettingundefined, that's why.
Catch-all routes
Great for docs sites where depth is unknown:
// app/docs/[...slug]/page.tsx
export default async function Docs({
params,
}: {
params: Promise<{ slug: string[] }>;
}) {
const { slug } = await params;
// /docs/getting-started/install → ["getting-started", "install"]
const doc = await getDocByPath(slug.join("/"));
return <DocRenderer doc={doc} />;
}
Want it to also match /docs with no segments? Use double brackets: [[...slug]].
Route groups (the one everyone gets confused by)
Parentheses mean "this folder exists for me, not for the URL":
app/
├── (marketing)/
│ ├── layout.tsx → marketing header + footer
│ ├── page.tsx → /
│ └── pricing/page.tsx → /pricing
└── (app)/
├── layout.tsx → sidebar + auth check
└── dashboard/page.tsx → /dashboard
/pricing and /dashboard both live at the top level of the URL, but they get completely different layouts. That's the real superpower of groups: different shells without ugly URL prefixes like /app/dashboard.
Same idea, now on mobile: Expo Router
Here's where it gets fun. React Native used to be the land of createStackNavigator and deeply nested navigator config. Expo Router brought file-based routing to mobile, and the conventions are almost identical:
app/
├── _layout.tsx → root Stack
├── (tabs)/
│ ├── _layout.tsx → bottom tab bar
│ ├── index.tsx → Home tab (/)
│ └── profile.tsx → Profile tab (/profile)
├── post/
│ └── [id].tsx → /post/123
└── +not-found.tsx → 404
Differences worth noting: layouts are _layout.tsx (underscore), screens are named files instead of page.tsx, and special files use a + prefix (+not-found.tsx, +api.ts for API routes).
The root layout
// app/_layout.tsx
import { Stack } from "expo-router";
export default function RootLayout() {
return (
<Stack>
<Stack.Screen name="(tabs)" options={{ headerShown: false }} />
<Stack.Screen name="post/[id]" options={{ title: "Post" }} />
</Stack>
);
}
Tabs via a route group
// app/(tabs)/_layout.tsx
import { Tabs } from "expo-router";
import { Ionicons } from "@expo/vector-icons";
export default function TabLayout() {
return (
<Tabs>
<Tabs.Screen
name="index"
options={{
title: "Home",
tabBarIcon: ({ color, size }) => (
<Ionicons name="home" color={color} size={size} />
),
}}
/>
<Tabs.Screen
name="profile"
options={{
title: "Profile",
tabBarIcon: ({ color, size }) => (
<Ionicons name="person" color={color} size={size} />
),
}}
/>
</Tabs>
);
}
Notice (tabs) doesn't show up in the URL. Same exact rule as Next.js route groups.
Reading params and navigating
// app/post/[id].tsx
import { useLocalSearchParams, Link } from "expo-router";
import { View, Text } from "react-native";
export default function PostScreen() {
const { id } = useLocalSearchParams<{ id: string }>();
return (
<View style={{ padding: 16 }}>
<Text>Post #{id}</Text>
<Link href={`/post/${Number(id) + 1}`}>Next post →</Link>
</View>
);
}
And the bonus you get for free: every screen is deep-linkable. myapp://post/42 just works, because the URL was the route all along. With config-based navigation, you'd be hand-writing a linking config for that.
Where do auth guards go?
This is the question I see most. Answer: in the layout that wraps the protected routes. Groups make this clean.
// app/(app)/_layout.tsx — Expo Router
import { Redirect, Stack } from "expo-router";
import { useSession } from "@/lib/auth";
export default function AppLayout() {
const { session, isLoading } = useSession();
if (isLoading) return null; // or a splash screen
if (!session) return <Redirect href="/sign-in" />;
return <Stack />;
}
Everything inside (app)/ is now protected. Everything outside it (sign-in.tsx, marketing screens) isn't. One file, one rule, zero per-screen checks.
In Next.js you'd typically do the same check in middleware.ts (runs before render) or in the group's layout.tsx with a redirect() from next/navigation.
If you'd rather skip the scaffolding
Honestly, the most tedious part of file-based routing isn't learning it, it's setting up the initial tree: root layout, tab group, auth group, a couple of dynamic screens, wiring the icons. It's the same 10 files every time.
I've been using RapidNative for that part: you describe the app, and it generates a React Native + Expo project with the Expo Router structure already in place, so you start at "customize the screens" instead of "create _layout.tsx again". Worth a look if you spin up mobile prototypes often.
Gotchas I've hit (so you don't have to)
1. Two files, one route. app/about.tsx and app/about/index.tsx both resolve to /about. Pick one convention and stick with it.
2. Groups don't isolate URLs. (marketing)/pricing and (app)/pricing would collide, since both become /pricing. Groups change layouts, not paths.
3. Colocating non-route files. In Next.js App Router, only page.tsx becomes a route, so you can safely drop components/ or utils.ts next to it. In Expo Router, every file in app/ is a route, so keep helpers outside (src/components, lib/), or prefix with _ where supported.
4. Layouts persist. A layout doesn't remount when you navigate between its children. Great for keeping tab state, surprising if you expected a useEffect in the layout to fire on every navigation.
5. Typed routes are worth turning on. Both Next.js and Expo Router can generate types from your file tree, so href="/pots/123" (typo) fails at compile time instead of at runtime:
// app.json (Expo)
{
"expo": {
"experiments": {
"typedRoutes": true
}
}
}
Should you use file-based routing?
My take:
- New project? Yes. Default to it. The discoverability alone pays for itself.
- Big existing app on config routing? Migrate only if navigation bugs are an actual pain point. It's a real refactor, especially on mobile.
- Need wildly dynamic, runtime-defined routes (e.g. a CMS defining the whole URL space)? Use a catch-all and resolve inside, or stay with config. File-based routing assumes your routes are known at build time.
The biggest win isn't fewer lines of code. It's that the answer to "where does this URL live?" is always "look at the folder."
What's your setup: Next.js, Expo Router, SvelteKit, Nuxt, or still hand-rolling routes? And what's the weirdest routing bug you've shipped? Drop a comment, I read all of them. 👇
Top comments (0)