skip to content

In an Expo Router web build, what is src/app/+html.tsx for, and why can it not read window or host context providers?

level: middleimportance: nice to knowfreq 15%

answer

  1. the document shell for every page
  2. runs only in Node.js
  3. no browser APIs, no effects
  4. providers and CSS go in _layout
  5. ScrollViewStyleReset from expo-router/html

basics

~20 s

+html.tsx customises the root HTML document that wraps every statically rendered page. It runs only in Node.js at render time, so window is undefined there, and providers belong in the root layout, which runs in the app.

solid answer

~30 s

`src/app/+html.tsx` default-exports a component that returns the `<html>`, `<head>` and `<body>` shell wrapped around every page rendered by `static` or `server` output. It is for document-level markup: the `lang` attribute, viewport and other global head tags, and `ScrollViewStyleReset` from `expo-router/html`. It runs **only in Node.js** during rendering, so `window`, `document` and effects are unavailable, and it never wraps the running app, which is why theme or auth providers and global CSS go in `src/app/_layout.tsx`. Per-page titles use `Head` from `expo-router/head`. Expo appends the JavaScript bundles itself, and with `single` output the template is `public/index.html` instead.

code

tsx · 11 lines
tsx
// src/app/_layout.tsx
import { Stack } from 'expo-router';
import { ThemeProvider } from '../theme';

export default function RootLayout() {
  return (
    <ThemeProvider>
      <Stack />
    </ThemeProvider>
  );
}

go deeper

for a junior

Recall that +html.tsx sets the HTML document around every web page and that it is a web-only file.

for a middle

Explain that it runs only in Node.js at render time, what that rules out, and what belongs in the root layout or Head instead.

for a senior

Spot the production mistakes: providers or CSS in the shell, browser APIs that break the export, a scroll reset that hurts a long mobile page.

for a principal

Treat the shell as a build artifact: keep it minimal and deterministic so exports stay reproducible and page-level concerns stay in components.

## What `+html.tsx` is When an **Expo Router** project exports its website with `web.output` set to `static` (or `server`), every page is rendered to HTML ahead of time. Each of those pages is wrapped in a small document shell: the `<html>`, `<head>` and `<body>` tags. That shell is the **root HTML**. You customise it by creating `src/app/+html.tsx`. The file default-exports a React component that receives `children` and returns the whole document. `npx expo customize` can scaffold it. For a habit-tracker app's marketing site, it is where site-wide head tags go: ```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 name="viewport" content="width=device-width, initial-scale=1" /> <ScrollViewStyleReset /> </head> <body>{children}</body> </html> ); } ``` ## It runs only in Node.js, at render time The most important property: **`+html.tsx` never runs in the browser or on a device**. Expo CLI executes it in Node.js while it renders the static pages. That has direct consequences: - **No browser APIs.** `window`, `document`, `localStorage` and `window.location` do not exist there. - **No state or effects.** It renders once per page to produce markup; hooks such as `useEffect` never fire on the client for it. - **No global CSS imports.** Import global styles in the root layout, `src/app/_layout.tsx`, instead. Expo Router walks the dependency graph from the root layout, and CSS imported elsewhere can load in an order where library CSS overrides yours. - **No context providers.** Theme, auth or data providers belong in the root layout, which does run on the client; a provider in the shell would not wrap the running app. ## What the shell receives and what Expo adds | Piece | Where it comes from | |---|---| | `children` | Includes the root `<div id="root" />` element the app renders into | | Page content | Rendered into that root element for each route | | React Native Web styles | Injected into the page automatically | | JavaScript bundles | Appended after the static render | So the shell does not need script tags for the app; adding them yourself would load the bundle twice. ## What goes where | Need | Put it in | |---|---| | `lang` attribute, viewport, a site-wide font preconnect | `+html.tsx` | | A body-scroll reset so a root `ScrollView` behaves as on native | `ScrollViewStyleReset` from `expo-router/html` in `+html.tsx` | | A page's own title and description | The `Head` component from `expo-router/head`, inside that page | | Global CSS | The root layout | | Theme, auth and query providers | The root layout | | A tag that must read `window` or a cookie | Client code in a layout or screen | `ScrollViewStyleReset` disables body scrolling so a full-screen root `ScrollView` scrolls like it does on native. On mobile web, body scrolling is often desirable, so remove it if the site is a long marketing page. ## How it relates to each output mode 1. **`static`**: the shell wraps every generated HTML file. This is the mode it was designed for. 2. **`server`**: pages are still pre-rendered, so the shell applies the same way. 3. **`single`**: there is only one HTML file, and its template is `public/index.html`, not `+html.tsx`. ## Mistakes interviewers look for - Reading `window.location` in the shell to add a canonical URL; it crashes the export because the file runs in Node.js. - Wrapping `children` in a provider and wondering why screens cannot read it at runtime. - Importing a global stylesheet there and getting the wrong cascade order. - Expecting it to affect the iOS or Android app; it is web-only. This is the behaviour of Expo SDK 57 with Expo Router 57.x.

  • Where should each page of a marketing site set its own title and description?
    In the page itself, with the `Head` component from `expo-router/head`, for example `<Head><title>Pricing</title></Head>`. With static output these head tags are rendered into each page's HTML at export, so crawlers and link previews see them. `+html.tsx` is for tags shared by every page, not page-specific metadata.
  • Should +html.tsx add a script tag for the app bundle?
    No. Expo appends the JavaScript bundles after the static render and injects React Native Web styles automatically. Adding the bundle by hand would load it twice or point at a stale hashed filename. The shell only needs to render `children`, which already contains the root element the app mounts into.

The root HTML is the printed frame around every page of a brochure: it is set once at the print shop, so it can carry the publisher's details, but nothing a reader does later can change it.

saying these in an interview costs you the question

  • Wrap children in providers in +html.tsx so every screen gets them
  • Read window.location in +html.tsx to build a canonical URL
  • Import global CSS in +html.tsx for the whole site
  • +html.tsx also shapes the iOS and Android app
  • Add the bundle's script tag to +html.tsx manually