Expo Router for Next.js Developers, a Translation Guide
If you've spent the last few years in the Next.js App Router, opening an Expo Router project feels familiar for about ten minutes. There's an app directory (src/app in Expo's current templates). There are _layout.tsx files, dynamic segments in square brackets and route groups in parentheses. You can guess the URL of most screens from the file tree.
The conventions were borrowed deliberately, and they carry you a long way. The trouble starts when you bring the runtime assumptions along with them. A Next.js route is a URL the server resolves into a page. An Expo Router route is a position in a tree of native navigators that happens to have a URL. Below is the mapping for the parts that translate, followed by the four places where the App Router mental model stops working.
The file conventions, mapped
Most of the vocabulary carries straight across:
app/layout.tsxisapp/_layout.tsx. The root layout is also where initialization code goes that would have lived in anApp.tsxin an older React Native project.app/blog/page.tsxisapp/blog/index.tsx, orapp/blog.tsx. Expo Router routes are files, not folders with apageinside.app/users/[id]/page.tsxisapp/users/[id].tsx, and catch-all[...slug]segments work the same way.app/(marketing)/about/page.tsxisapp/(marketing)/about.tsx. Groups in parentheses stay out of the URL in both.not-found.tsxis+not-found.tsx.error.tsxbecomes anErrorBoundaryexport from the route file itself, which receiveserrorand aretryfunction.app/api/hello/route.tsisapp/hello+api.ts, an API route that runs on a server you deploy separately.proxy.ts(formerlymiddleware.ts) is+middleware.ts, with an important caveat covered below.
A few App Router features have no counterpart. Parallel routes (@slot folders) and intercepting routes ((.)photo) don't exist in Expo Router. The patterns they solve on the web, such as a modal that keeps its own URL, are handled by navigator options instead, like presenting a stack screen as a modal.
The + prefix is Expo's marker for special files. Besides the ones above, +html.tsx customizes the HTML shell on web and +native-intent.tsx rewrites incoming deep links, which matters later.
Layouts render navigators, not wrappers
In Next.js, a layout wraps its children in shared UI and persists across navigation between sibling pages. It doesn't decide how pages transition. The browser handles history and the page swaps in place.
An Expo Router _layout.tsx usually returns a navigator:
import { Stack } from 'expo-router';
export default function Layout() {
return <Stack />;
}
That one line changes how every route in the folder behaves. A Stack pushes screens on top of each other with native transitions and a back gesture. Tabs keeps several screens alive side by side, and each tab can hold its own stack. A modal is a stack screen with a different presentation option. The layout is where you decide what a route is in the UI.
The practical habit to build: when you add a folder, decide its navigator first. "Should this be a tab, a pushed screen or a modal?" is the native equivalent of deciding a route's URL structure, and getting it wrong is more expensive to undo.
Navigation state is a tree, not a URL
On the web, the URL is the state. Reload the page and you get the same screen. Browser history is a flat list of URLs.
In Expo Router, every screen still has a URL, but the real state is a tree. A tab navigator holds a stack per tab, each stack holds a list of mounted screens, and the URL is a projection of whichever screen is focused. That has consequences for everyday code.
Screens stay mounted. When you push a new screen onto a stack, the previous one stays in memory so the user can swipe back without a reload. A useEffect cleanup on the earlier screen doesn't run until it's popped. Work that should pause when a screen goes into the background, like polling, a camera session or an analytics "page view", belongs in useFocusEffect instead.
There are two kinds of params. Because several screens with the same dynamic segment can be mounted at once, Expo Router gives you useLocalSearchParams and useGlobalSearchParams. The local version only updates when the URL matches the current screen. The global version updates on every URL change, including ones meant for other screens, which can re-render background screens for no reason. Default to the local hook. It's the closest match to the params a Next.js page receives.
push isn't always what you want. The router has push, replace and back, which look like next/navigation. It also has navigate, which either pushes a new screen or unwinds to an existing instance of that route already on the stack. Calling push to the same route repeatedly stacks up duplicate screens that the user has to back out of one by one. Expo's docs recommend navigate as the usual choice.
<Link> also differs in a small way that surprises people: it renders its children inside a <Text> by default. To wrap a card or a button, pass asChild and give it a Pressable.
Data loading moves to the client
The App Router's default is a server component that awaits data before rendering. There's no direct equivalent on native. A screen in an iOS or Android build runs on the device, so data loading usually happens on the client. The common pattern is TanStack Query (or a similar cache) inside the screen, keyed on the route params:
import { useLocalSearchParams } from 'expo-router';
import { useQuery } from '@tanstack/react-query';
export default function Order() {
const { id } = useLocalSearchParams<{ id: string }>();
const { data, isPending } = useQuery({
queryKey: ['order', id],
queryFn: () => getOrder(id),
});
// ...
}
That shifts responsibilities a server component used to cover. You handle loading and error states on every screen, caching and retries move into the query library, and secrets can't live in screen code, because everything in the bundle ships to the device.
Expo Router does have server-side data loading, but it's easy to read more into it than it offers today. Routes can export a loader function and read it with useLoaderData, similar to Remix. Expo documents loaders under its web section. They need web.output set to static or server, and they sit behind the unstable_useServerDataLoaders flag from SDK 55 until the release Expo's docs mark them stable. For a native-first app, treat loaders as a web feature and keep client fetching as the default.
API routes (+api.ts) cover the remaining need. If a screen needs a secret, put the call behind an API route on a deployed server and call that from the client, the same way you'd treat a public API.
Deep links arrive without history
A Next.js page can assume very little about how the user got there, and that's fine, because the browser has a back button and every URL renders on its own. A native app has more to get right when a link opens it from a notification, an email or another app.
The back button needs somewhere to go. If a deep link opens /orders/42 directly, the stack holds one screen and there's nothing to go back to. In a layout, export const unstable_settings = { anchor: 'index' } tells Expo Router to put the index screen underneath, so the back arrow still appears. During normal in-app navigation the anchor isn't used unless you pass withAnchor to the link.
Auth is a client-side guard. On the web, you might redirect unauthenticated requests in proxy.ts. Expo Router's +middleware.ts runs on the server for HTTP requests only. Its docs say plainly that it doesn't run during client-side navigation with <Link> or router, or during native screen transitions. On device, the tool is Stack.Protected:
<Stack>
<Stack.Protected guard={!isSignedIn}>
<Stack.Screen name="sign-in" />
</Stack.Protected>
<Stack.Protected guard={isSignedIn}>
<Stack.Screen name="(app)" />
</Stack.Protected>
</Stack>
When a guard is false, navigation to those screens redirects to the anchor route. If a guard flips while the user is on a protected screen, they're redirected and that screen's history is removed. That covers deep links into protected routes as well as sign-out.
Not every incoming link is one of your URLs. Marketing tools and older app versions can open the app with a provider-specific URL or a path that no longer exists. +native-intent.tsx exports a redirectSystemPath function that receives the raw path and whether this is the initial launch, and returns the route to open. Nothing in Next.js does this job, because a web server just returns a 404 or a redirect.
Where to start translating
Port the file structure first, since it maps almost mechanically, and then work through the runtime differences in this order:
- Pick a navigator for every folder before writing screens.
- Audit effects for work that should stop when a screen loses focus, and move it to
useFocusEffect. - Use
useLocalSearchParamsby default and reach for the global hook only when a background screen needs to react to URL changes. - Pick a client data layer and decide which calls need an API route to keep secrets off the device.
- Test every deep link from a cold start, with and without a signed-in user, and set an
anchoranywhere the back button disappears.
If you want type checking on all of this, enable experiments.typedRoutes in app.json. Expo CLI then generates route types so <Link href> and router.navigate reject paths that don't exist. Expo still labels it beta, but it catches the class of bug that file-based routing invites, such as renaming a file and leaving stale links behind.
App Router experience gets you through the file tree on day one. Navigation state, screen lifecycles and cold-start deep links take longer, since a server-rendered web app never had to model them.
New posts by email
Sent when they're published. No spam, unsubscribe anytime.