Migration prerequisites: refactor and structure recommendations
Before migrating from React Navigation, make these recommended modifications: Split React Navigation screen components into individual files. Convert the project to TypeScript. Convert relative imports to typed aliases (e.g., ../../components/button.tsx to @/components/button). Migrate away from resetRoot. Rename the initial route to index since Expo Router considers the route opened on launch to match /, whereas React Navigation typically uses something like Home.
File naming convention: kebab-case and lowercase
Kebab-case and lowercase letters are considered best practice for route filenames when migrating to Expo Router.
Move app directory to src/app
When setting up a top-level src directory in an existing project, move your app directory to src/app.
Default src directory structure in SDK 55+
Projects created with the default template on SDK 55 and later include a top-level src directory that contains the app, components, constants, and hooks directories. No extra configuration is needed.
Update TypeScript path aliases for src directory
Update TypeScript path aliases in tsconfig.json to point to the src directory instead of the root directory. If you use the default @/* alias, set it to ./src/* to keep @/ imports working after moving your app directory into src.
Restart development server after moving app to src
After moving your app directory to src/app and updating tsconfig.json, restart the development server using npx expo start (or the equivalent for yarn, pnpm, or bun).
Config files remain in project root
Config files including app.config.ts, app.json, package.json, metro.config.js, and tsconfig.json should remain in the root directory, not in the src directory.
src/app directory takes precedence over root app directory
The src/app directory takes higher precedence than a root app directory. Only the src/app directory will be used if both exist.
Public directory remains in project root
The public directory should remain in the root directory when using a src directory structure.
Static rendering uses src/app directory automatically
Static rendering will automatically use the src/app directory if it exists.
Dynamic routes with server rendering do not need generateStaticParams
With server rendering, dynamic routes are rendered on the fly at request time. The `generateStaticParams` export is not needed and should be removed. If a route file exports `generateStaticParams`, those routes will be handled dynamically instead.
generateMetadata function for server rendering
Routes may export a `generateMetadata` function to define per-page metadata such as title, description, and Open Graph tags. This function runs on the server before rendering begins and receives the incoming request and route parameters. It returns a `Metadata` object. The function executes on the server and is stripped from the client bundle, similar to data loaders.
generateMetadata vs Head component in server rendering
Both `generateMetadata` and the `<Head>` component from `expo-router/head` can be used to add metadata. However, `generateMetadata` is the recommended approach for server rendering because it resolves metadata before the HTML stream begins, ensuring `<meta>` tags are included in the earliest bytes of the response. `<Head>` can update `<meta>` tags dynamically after hydration.
Dynamic route parameters in server rendering
When a dynamic route is accessed in server rendering, the route parameters are available immediately from the URL. For example, when visiting `/blog/my-post`, a route file at `src/app/blog/[id].tsx` is rendered with the `id` parameter set to `"my-post"`.
Create root HTML document with +html.tsx
Customize the root HTML document by creating a `src/app/+html.tsx` file. This component wraps all routes and runs only on the server. It must use the `useServerDocumentContext` hook from `expo-router/html` and spread all returned properties into the HTML to ensure metadata, fonts, and CSS are included.
useServerDocumentContext hook properties
The `useServerDocumentContext` hook from `expo-router/html` returns: (1) `htmlAttributes` - attributes to add to the `<html>` element, (2) `bodyAttributes` - attributes to add to the `<body>` element, (3) `headNodes` - React nodes for the `<head>` element (metadata, CSS, and other assets), and (4) `bodyNodes` - React nodes for the `<body>` element (fonts and other deferred assets).
+html.tsx is server-only and must use useServerDocumentContext
The +html.tsx file is only used by the server renderer and never by client code. It runs by `expo-server` during server rendering but is not rehydrated on the client. You must only use the `useServerDocumentContext` hook in this file. You may not import global CSS in +html.tsx (use the Root Layout instead), call browser APIs like `window` or `document`, or rehydrate on the client.
Customize root HTML with +html.tsx file
Create a src/app/+html.tsx file to customize the root HTML that wraps all routes in the app directory. This component only runs in Node.js, so global CSS cannot be imported inside of it. It is useful for adding global head elements or disabling body scrolling.
Static rendering executes data loaders at build time
With static rendering, data loaders are executed during the build process and their results are embedded in the output HTML files.
Export command for production static website
To bundle a static website for production, run `npx expo export --platform web`. This creates a dist directory with the statically rendered website. Files in a local public directory are copied over as well.
Dynamic routes require generateStaticParams function
Dynamic routes (for example, src/app/[id].tsx) will not work out of the box with static output. You must use the generateStaticParams function to generate known routes ahead of time. This function returns an array of params objects, with each entry generating a new static HTML file.
generateStaticParams execution environment
generateStaticParams is a server-only function evaluated at build-time in a Node.js environment by Expo CLI. It has access to __dirname, process.cwd(), process.env, and all environment variables. It cannot access browser APIs such as localStorage or document, and cannot access native Expo APIs such as expo-camera or expo-location.
generateStaticParams cascades from parents to children
generateStaticParams cascades from nested parent routes down to child routes. Cascading parameters are passed to every dynamic child route that exports generateStaticParams. Child routes receive parent parameters and must pass them down in their return values.
Read files in generateStaticParams using process.cwd()
Use process.cwd() instead of __dirname in generateStaticParams to form file paths, because Expo Router compiles code into a separate directory where __dirname has a different value than expected.
+html.tsx component structure
The +html.tsx component receives a children prop that comes with a root div with id="root" included inside. JavaScript scripts are appended after the static render. React Native web styles are statically injected automatically.
+html.tsx cannot import global CSS
Global CSS should not be imported into the +html.tsx file. Instead, use the Root Layout. Expo Router traverses the dependency graph starting from the root layout, so importing CSS elsewhere can cause unexpected loading order where node_modules CSS takes precedence over custom styles.
ScrollViewStyleReset from expo-router/html
ScrollViewStyleReset is exported from expo-router/html and provides root style-reset for full-screen React Native web apps with a root ScrollView component to ensure native parity.
Add meta tags with Head component from expo-router
Use the Head component from expo-router to add meta tags to pages. Head elements can be updated dynamically using the same API. For SEO, it is useful to have static head elements rendered ahead of time.
Public directory for static assets
Expo CLI supports a root public directory that gets copied to the dist directory during static rendering. This is useful for adding static files like images, fonts, and other assets. Some paths such as /assets are reserved by Metro and should be avoided.
Access static assets with relative paths at runtime
Static assets can be accessed in runtime code using relative paths. For example, logo.png placed in the public directory can be accessed at /logo.png in the application code.
Expo Font automatic static optimization
Expo Font has automatic static optimization for font loading in Expo Router. When you load a font with expo-font, Expo CLI automatically extracts the font resource and embeds it in the page's HTML, enabling preloading, faster hydration, and reduced layout shift.
Font static optimization requirements
Static font optimization requires the font to be loaded synchronously. If the font is loaded inside useEffect, a deferred component, or async function, it will not be statically optimized. Static optimization is only supported with Font.loadAsync and Font.useFonts from expo-font, and wrapper functions as long as they are synchronous.
Static rendering not a single-page application
The static website generated by expo export is not a single-page application and does not contain a custom server API. It is a collection of static HTML files. You don't need to add single-page application styled redirects to your static hosting service.
Dynamic routes deployment considerations
With static output, dynamic routes (for example, src/app/[id].tsx) will not arbitrarily work on static hosting. You may need to build a serverless function to handle dynamic routes.
Example generateStaticParams for blog posts
```tsx src/app/blog/[id].tsx
import { Text } from 'react-native';
import { useLocalSearchParams } from 'expo-router';
export async function generateStaticParams(): Promise<Record<string, string>[]> {
const posts = await getPosts();
return posts.map(post => ({ id: post.id }));
}
export default function Page() {
const { id } = useLocalSearchParams();
return <Text>Post {id}</Text>;
}
```
This example shows how to use generateStaticParams to generate static HTML files for each blog post.
Example nested generateStaticParams
```tsx src/app/[id]/[comment].tsx
export async function generateStaticParams(params: {
id: 'one' | 'two';
}): Promise<Record<string, string>[]> {
const comments = await getComments(params.id);
return comments.map(comment => ({
...params,
comment: comment.id,
}));
}
```
This example shows how to handle cascading parameters in nested dynamic routes, where parent properties must be passed down in the return value.
Example +html.tsx customization
```tsx src/app/+html.tsx
import { ScrollViewStyleReset } from 'expo-router/html';
import { type PropsWithChildren } from 'react';
export default function Root({ children }: PropsWithChildren) {
return (
<html lang="en">
<head>
<meta charSet="utf-8" />
<meta httpEquiv="X-UA-Compatible" content="IE=edge" />
<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no" />
<ScrollViewStyleReset />
</head>
<body>{children}</body>
</html>
);
}
```
This example shows how to customize the root HTML by creating a +html.tsx file with custom head elements and ScrollViewStyleReset.
Example meta tags with Head component
```tsx src/app/about.tsx
import Head from 'expo-router/head';
import { Text } from 'react-native';
export default function Page() {
return (
<>
<Head>
<title>My Blog Website</title>
<meta name="description" content="This is my blog." />
</Head>
<Text>About my blog</Text>
</>
);
}
```
This example shows how to add meta tags to a page using the Head component from expo-router.
Example static font optimization with expo-font
```tsx src/app/home.tsx
import { Text } from 'react-native';
import { useFonts } from 'expo-font';
export default function App() {
const [isLoaded] = useFonts({
inter: require('@/assets/inter.ttf'),
});
if (!isLoaded) {
return null;
}
return <Text style={{ fontFamily: 'inter' }}>Hello Universe</Text>;
}
```
This example shows how to load a font with expo-font, which Expo CLI automatically extracts and optimizes during static rendering.
Example accessing static assets
```tsx src/app/index.tsx
import { Image } from 'react-native';
export default function Page() {
return <Image source={{ uri: '/logo.png' }} />;
}
```
Static assets placed in the public directory can be accessed using relative paths in runtime code.
Enable static rendering in app.json
To enable static rendering, add `"web": { "output": "static" }` to the expo config in app.json.
API Routes file naming convention
API routes are defined by creating files in the app directory with the `+api.ts` extension. For example, a file named `hello+api.ts` creates an API route executed when the route `/hello` is matched. Platform-specific extensions are not supported; for example, `hello+api.web.ts` will not work.
API route HTTP method handlers
API routes can export functions for HTTP methods: `GET`, `POST`, `PUT`, `PATCH`, `DELETE`, `HEAD`, and `OPTIONS`. The function executes when the corresponding HTTP method is matched. Unsupported methods automatically return `405: Method not allowed`.
Basic API route example
```ts src/app/hello+api.ts
export function GET(request: Request) {
return Response.json({ hello: 'world' });
}
```
This example shows a GET handler that returns a JSON response when the `/hello` route is matched.
Request body parsing
Use the `request.json()` function to access and parse the request body in an API route. It automatically parses the body and returns the result.
```ts src/app/validate+api.ts
export async function POST(request: Request) {
const body = await request.json();
return Response.json({ ... });
}
```
Query parameter access in API routes
Query parameters are accessed by parsing the request URL using the standard `URL` class. The search parameters can be extracted using `url.searchParams.get()`.
```ts src/app/endpoint+api.ts
export async function GET(request: Request) {
const url = new URL(request.url);
const post = url.searchParams.get('post');
return Response.json({ ... });
}
```
Dynamic route segments in API routes
API routes support dynamic route segments using bracket notation. The matched segment is passed as a parameter to the handler function.
```ts src/app/blog/[post]+api.ts
export async function GET(request: Request, { post }: Record<string, string>) {
// Access the 'post' parameter from the URL
return Response.json({ ... });
}
```
Error responses in API routes
Error responses can be created with any status code and response body. Errors thrown during a request automatically return `500: Internal server error`. Use the standard `Response` constructor to specify status and headers.
```ts src/app/blog/[post]+api.ts
export async function GET(request: Request, { post }: Record<string, string>) {
if (!post) {
return new Response('No post found', {
status: 404,
headers: { 'Content-Type': 'text/plain' },
});
}
return Response.json({ ... });
}
```
StatusError for error handling in API routes
`StatusError` from the `expo-server` library is a special Error instance that throws an HTTP response with a JSON body containing an `error` key. It accepts a status code and error message, and interrupts execution early.
```ts src/app/blog/[post]+api.ts
import { StatusError } from 'expo-server';
export async function GET(request: Request, { post }: Record<string, string>) {
if (!post) {
throw new StatusError(404, 'No post found');
}
}
```
Throwing Response objects in API routes
Instead of returning a Response, you can throw a Response object to interrupt logic and replace the resolved Response directly, without wrapping it in a StatusError. This is useful for redirect responses.
```ts src/app/blog/[post]+api.ts
export async function GET(request: Request, { post }: Record<string, string>) {
if (!post) {
throw Response.redirect('https://expo.dev', 302);
}
}
```
origin() helper for request origin URL
The `origin()` helper function from `expo-server` retrieves the request's origin URL, which is typically transmitted on the `Origin` header. This represents the URL a user used to access the API route and may differ from internal deployment URLs due to proxying.
```ts src/app/help+api.ts
import { origin } from 'expo-server';
export async function GET(request: Request) {
const target = new URL('/help', origin() ?? request.url);
return Response.redirect('https://expo.dev', 302);
}
```
environment() helper for deployment environment
The `environment()` helper function from `expo-server` returns the environment name, differentiating between production or staging deployments. The value differs depending on how the server code is running.
```ts src/app/env+api.ts
import { environment } from 'expo-server';
export async function GET(request: Request) {
const env = environment();
if (env === 'staging') {
return Response.json({ isStaging: true });
} else if (!env) {
return Response.json({ isProduction: true });
} else {
return Response.json({ env });
}
}
```
expo-server library availability
The `expo-server` library was added in SDK 54. For older SDKs, use `@expo/server` instead. The library provides server-side runtime utilities for Expo API routes and React Server Components.
runTask() for concurrent background tasks
The `runTask()` helper from `expo-server` runs asynchronous tasks concurrently without delaying the API route response. Tasks are guaranteed to complete, preventing serverless functions from quitting early, but do not block the response.
```ts src/app/tasks+api.ts
import { runTask } from 'expo-server';
export async function GET(request: Request) {
runTask(async () => {
await pingAnalytics(...);
});
const data = await fetchExampleData(...);
return Response.json({ data });
}
```
deferTask() for post-response execution
The `deferTask()` helper from `expo-server` schedules tasks to run after the API route's Response has been resolved. Use this when you want to skip a task if the API rejects or to prevent computation-heavy tasks from delaying time-sensitive operations.
```ts src/app/tasks+api.ts
import { deferTask } from 'expo-server';
export async function GET(request: Request) {
deferTask(async () => {
await pingAnalytics(...);
});
const data = await fetchExampleData(...);
return Response.json({ data });
}
```
setResponseHeaders() for modifying response headers
The `setResponseHeaders()` function from `expo-server` allows modifying response headers before a Response has been created. It can be used in server middleware to add metadata, rate-limit headers, cookies, or authentication headers to future responses.
```ts src/app/+middleware.ts
import { setResponseHeaders } from 'expo-server';
export default function middleware(request: Request) {
setResponseHeaders({ 'Retry-After': '3600' });
// Or append headers using a callback:
setResponseHeaders(headers => {
headers.append('Set-Cookie', 'token=123; Secure');
});
}
```
API routes bundling and language features
API routes are bundled with Expo CLI and Metro bundler, and have access to: TypeScript with tsconfig.json path aliases, all environment variables (not just EXPO_PUBLIC_ prefixed ones), Node.js standard library, babel.config.js and metro.config.js settings.
API route security and secret isolation
Route handlers are executed in a sandboxed environment isolated from client code. Secrets in `<...>+api.ts` files are not included in the client bundle, and the secret stripping is handled by `expo/metro-config` in **metro.config.js**. However, if client code imports code containing a secret, it will be included in the client bundle, even for non-route-handler files in the **src/app** directory.
Client-side fetch to API routes with origin configuration
Relative fetch requests in client code automatically fetch relative to the dev server origin in development. In production, configure the `origin` field in **app.json** for the `expo-router` plugin to specify where API routes are hosted.
```json app.json
{
"plugins": [
[
"expo-router",
{
"origin": "https://evanbacon.dev/"
}
]
]
}
```
Automatic server deployment with EXPO_UNSTABLE_DEPLOY_SERVER
Setting `EXPO_UNSTABLE_DEPLOY_SERVER=1` enables automatic server deployment during EAS Builds, which triggers versioned server deployment and sets the origin to a preview deploy URL automatically. This eliminates the need to manually configure the origin in app.json.