1--- 2title: Static Rendering 3description: Learn how to render routes to static HTML and CSS files with Expo Router. 4--- 5 6import { Terminal } from '~/ui/components/Snippet'; 7import { FileTree } from '~/ui/components/FileTree'; 8import { Step } from '~/ui/components/Step'; 9import { Tabs, Tab } from '~/ui/components/Tabs'; 10import { APIBox } from '~/components/plugins/APIBox'; 11 12> Available from Expo SDK 49 and Expo Router v2. 13 14To enable Search Engine Optimization (SEO) on the web you must statically render your app. This guide will walk you through the process of statically rendering your Expo Router app. 15 16## Setup 17 18<Step label="1"> 19 Enable metro bundler and static rendering in the project's [app config](/versions/latest/config/app/): 20 21```json app.json 22{ 23 "expo": { 24 /* @hide ... */ 25 /* @end */ 26 "web": { 27 /* @info Static rendering is only supported with Metro bundler and Expo Router */ 28 "bundler": "metro", 29 /* @end */ 30 "output": "static" 31 } 32 } 33} 34``` 35 36</Step> 37 38<Step label="2"> 39 If you have a **metro.config.js** file in your project, ensure it extends **expo/metro-config** as shown below: 40 41```js metro.config.js 42const { getDefaultConfig } = require('expo/metro-config'); 43 44/** @type {import('expo/metro-config').MetroConfig} */ 45const config = getDefaultConfig(__dirname, { 46 // Additional features... 47}); 48 49module.exports = config; 50``` 51 52You can also [learn more](/guides/customizing-metro/) about customizing Metro. 53 54</Step> 55 56<Step label="3"> 57 Expo Router requires at least `[email protected]`. React Native hasn't upgraded yet, so you need to force upgrade your `react-refresh` version by setting a Yarn resolution or npm override. 58 59 <Tabs> 60 <Tab label="Yarn"> 61 ```json package.json 62 { 63 /* @hide ... */ 64 /* @end */ 65 "resolutions": { 66 "react-refresh": "~0.14.0" 67 } 68 } 69 ``` 70 </Tab> 71 <Tab label="npm"> 72 ```json package.json 73 { 74 /* @hide ... */ 75 /* @end */ 76 "overrides": { 77 "react-refresh": "~0.14.0" 78 } 79 } 80 ``` 81 </Tab> 82 </Tabs> 83</Step> 84 85<Step label="4"> 86 Finally, start the development server: 87 88 <Terminal cmd={['$ npx expo start']} /> 89</Step> 90 91## Production 92 93To bundle your static website for production, run the universal export command: 94 95<Terminal cmd={['$ npx expo export --platform web']} /> 96 97This will create a **dist** directory with your statically rendered website. If you have files in a local **public** directory, these will be copied over as well. 98You can test the production build locally by running the following command and opening the linked URL in your browser: 99 100<Terminal cmd={['$ npx serve dist']} /> 101 102This project can be deployed to almost every hosting service. Note that this is not a single-page application, nor does it contain a custom server API. This means dynamic routes (**app/[id].tsx**) will not arbitrarily work. You may need to build a serverless function to handle dynamic routes. 103 104## Dynamic Routes 105 106The `static` output will generate HTML files for each route. This means dynamic routes (**app/[id].tsx**) will not work out of the box. You can generate known routes ahead of time using the `generateStaticParams` function. 107 108```tsx app/blog/[id].tsx 109import { Text } from 'react-native'; 110import { useLocalSearchParams } from 'expo-router'; 111 112/* @info This method is run in a Node.js environment at build-time. */ 113export async function generateStaticParams(): Promise<Record<string, string>[]> { 114 /* @end */ 115 const posts = await getPosts(); 116 // Return an array of params to generate static HTML files for. 117 // Each entry in the array will be a new page. 118 return posts.map(post => ({ id: post.id })); 119} 120 121export default function Page() { 122 const { id } = useLocalSearchParams(); 123 124 return <Text>Post {id}</Text>; 125} 126``` 127 128This will output a file for each post in the **dist** directory. For example, if the `generateStaticParams` method returned `[{ id: "alpha" }, { id: "beta" }]`, the following files would be generated: 129 130<FileTree files={['dist/blog/alpha.html', 'dist/blog/beta.html']} /> 131 132<APIBox header="generateStaticParams"> 133 134A server-only function evaluated at build-time in a Node.js environment by Expo CLI. This means it has access to `__dirname`, `process.cwd()`, `process.env`, and more. It also has access to every environment variable that's available in the process, not just the values prefixed with **EXPO*PUBLIC***. **generateStaticParams** is not run in a browser environment, so it cannot access browser APIs like **localStorage** or **document**, nor can it access native Expo APIs such as **expo-camera** or **expo-location**. 135 136```tsx app/[id].tsx 137export async function generateStaticParams(): Promise<Record<string, string>[]> { 138 /* @info Prints the current working directory */ 139 console.log(process.cwd()); 140 /* @end */ 141 142 return []; 143} 144``` 145 146**generateStaticParams** cascades from nested parents down to children. The cascading parameters are passed to every dynamic child route that exports **generateStaticParams**. 147 148```tsx app/[id]/_layout.tsx 149export async function generateStaticParams(): Promise<Record<string, string>[]> { 150 /* @info Any dynamic children that export <b>generateStaticParams</b> will be invoked once for every entry in the array. */ 151 return [{ id: 'one' }, { id: 'two' }]; 152 /* @end */ 153} 154``` 155 156Now the dynamic child routes will be invoked twice, once with `{ id: 'one' }` and once with `{ id: 'two' }`. All variations must be accounted for. 157 158```tsx app/[id]/[comment].tsx 159export async function generateStaticParams(params: { 160 id: 'one' | 'two'; 161}): Promise<Record<string, string>[]> { 162 const comments = await getComments(params.id); 163 return comments.map(comment => ({ 164 /* @info Ensure the parent properties are passed down too. */ 165 ...params, 166 /* @end */ 167 comment: comment.id, 168 })); 169} 170``` 171 172</APIBox> 173 174## Root HTML 175 176By default, every page is wrapped with some small HTML boilerplate, this is known as the **root HTML**. 177 178You can customize the root HTML file by creating an **app/+html.tsx** file in your project. This file exports a React component that only ever runs in Node.js, which means global CSS cannot be imported inside of it. The component will wrap all routes in the **app** directory. This is useful for adding global `<head>` elements or disabling body scrolling. 179 180> **Note**: Global context providers should go in the [Root Layout](/router/advanced/root-layout) component, not the Root HTML component. 181 182```tsx app/+html.tsx 183import { ScrollViewStyleReset } from 'expo-router/html'; 184import type { PropsWithChildren } from 'react'; 185 186// This file is web-only and used to configure the root HTML for every 187// web page during static rendering. 188// The contents of this function only run in Node.js environments and 189// do not have access to the DOM or browser APIs. 190export default function Root({ children }: PropsWithChildren) { 191 return ( 192 <html lang="en"> 193 <head> 194 <meta charSet="utf-8" /> 195 <meta httpEquiv="X-UA-Compatible" content="IE=edge" /> 196 197 {/* 198 This viewport disables scaling which makes the mobile website act more like a native app. 199 However this does reduce built-in accessibility. If you want to enable scaling, use this instead: 200 <meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" /> 201 */} 202 <meta 203 name="viewport" 204 content="width=device-width,initial-scale=1,minimum-scale=1,maximum-scale=1.00001,viewport-fit=cover" 205 /> 206 {/* 207 Disable body scrolling on web. This makes ScrollView components work closer to how they do on native. 208 However, body scrolling is often nice to have for mobile web. If you want to enable it, remove this line. 209 */} 210 <ScrollViewStyleReset /> 211 212 {/* Add any additional <head> elements that you want globally available on web... */} 213 </head> 214 <body>{children}</body> 215 </html> 216 ); 217} 218``` 219 220- The `children` prop comes with the root `<div id="root" />` tag included inside. 221- The JavaScript scripts are appended after the static render. 222- React Native web styles are statically injected automatically. 223- Global CSS should not be imported into this file. Instead, use the [Root Layout](/router/advanced/root-layout) component. 224- Browser APIs like `window.location` are unavailable in this component as it only runs in Node.js during static rendering. 225 226### `expo-router/html` 227 228The exports from `expo-router/html` are related to the Root HTML component. 229 230- `ScrollViewStyleReset`: Root style-reset for full-screen [React Native web apps](https://necolas.github.io/react-native-web/docs/setup/#root-element) with a root `<ScrollView />` should use the following styles to ensure native parity. 231 232## Meta tags 233 234You can add meta tags to your pages with the `<Head />` module from `expo-router`: 235 236```tsx app/about.tsx 237import Head from 'expo-router/head'; 238import { Text } from 'react-native'; 239 240export default function Page() { 241 return ( 242 <> 243 <Head> 244 <title>My Blog Website</title> 245 <meta name="description" content="This is my blog." /> 246 </Head> 247 <Text>About my blog</Text> 248 </> 249 ); 250} 251``` 252 253The head elements can be updated dynamically using the same API. However, it's useful for SEO to have static head elements rendered ahead of time. 254 255## Static Files 256 257Expo CLI supports a root **public** directory that gets copied to the **dist** folder during static rendering. This is useful for adding static files like images, fonts, and other assets. 258 259<FileTree 260 files={['public/favicon.ico', 'public/logo.png', 'public/.well-known/apple-app-site-association']} 261/> 262 263These files will be copied to the **dist** folder during static rendering: 264 265<FileTree 266 files={[ 267 'dist/index.html', 268 'dist/favicon.ico', 269 'dist/logo.png', 270 'dist/.well-known/apple-app-site-association', 271 'dist/_expo/static/js/index-xxx.js', 272 'dist/_expo/static/css/index-xxx.css', 273 ]} 274/> 275 276> **info** **Web only**: Static assets can be accessed in runtime code using relative paths. For example, the **logo.png** can be accessed at `/logo.png`: 277 278```tsx app/index.tsx 279import { Image } from 'react-native'; 280 281export default function Page() { 282 return <Image source={{ uri: '/logo.png' }} />; 283} 284``` 285 286## Fonts 287 288> Font optimization is available in SDK 50 and above. 289 290Expo Font has automatic static optimization for font loading in Expo Router. When you load a font with `expo-font`, Expo CLI will automatically extract the font resource and embed it in the page's HTML, enabling preloading, faster hydration, and reduced layout shift. 291 292The following snippet will load Inter into the namespace and statically optimize on web: 293 294```js app/home.js 295import { Text } from 'react-native'; 296import { useFonts } from 'expo-font'; 297 298export default function App() { 299 /* @info Expo CLI automatically finds and extracts this font during compilation. */ 300 const [isLoaded] = useFonts({ 301 /* @end */ 302 inter: require('@/assets/inter.ttf'), 303 }); 304 305 /* @info Always true on web with static rendering enabled. */ 306 if (!isLoaded) { 307 /* @end */ 308 return null; 309 } 310 311 return <Text style={{ fontFamily: 'inter' }}>Hello Universe</Text>; 312} 313``` 314 315This generates the following static HTML: 316 317```html dist/home.html 318/* @info preload the font before the JavaScript loads. */ 319<link rel="preload" href="/assets/inter.ttf" as="font" crossorigin /> 320/* @end */ 321<style id="expo-generated-fonts" type="text/css"> 322 @font-face { 323 font-family: inter; 324 src: url(/assets/inter.ttf); 325 font-display: auto; 326 } 327</style> 328``` 329 330- Static font optimization requires the font to be loaded syncronously. If the font isn't statically optimized, it could be because it was loaded inside a `useEffect`, deferred component, or async function. 331- Static optimization is only supported with `Font.loadAsync` and `Font.useFonts` from `expo-font`. Wrapper functions are supported as long as the wrappers are synchronous. 332 333## FAQ 334 335### How do I add a custom server? 336 337As of Expo Router v2 there is no prescriptive way to add a custom server. You can use any server you want. However, you will need to handle dynamic routes yourself. You can use the `generateStaticParams` function to generate static HTML files for known routes. 338 339In future, there will be a server API, and a new `web.output` mode which will generate a project that will (amongst other things) support dynamic routes. 340 341## Server-side Rendering 342 343Rendering at request-time (SSR) is not supported in `web.output: 'static'`. This will likely be added in a future version of Expo Router. 344 345### Where can I deploy statically rendered websites? 346 347You can deploy your statically rendered website to any static hosting service. Here are some popular options: 348 349- [Netlify](https://www.netlify.com/) 350- [Cloudflare Pages](https://pages.cloudflare.com/) 351- [AWS Amplify](https://aws.amazon.com/amplify/) 352- [Vercel](https://vercel.com/) 353- [GitHub Pages](https://pages.github.com/) 354- [Render](https://render.com/) 355- [Surge](https://surge.sh/) 356 357> Note: You don't need to add Single-Page Application styled redirects to your static hosting service. The static website is not a single-page application. It is a collection of static HTML files. 358