Makes an Expo app's web build a deliberate, working target: the web is declared or left out on purpose, platform files resolve the way Metro and TypeScript expect, nothing in the web bundle imports a module or calls a React Native API that fails in a browser, expo-sqlite gets its wasm, isolation headers, warm-up and single-tab lock, and the exported site is opened in a real browser before it ships. Two zero-dependency checkers prove it; every fact below was measured in headless Chrome against an export of a canonical Tasks app (Expo SDK 57, react-native-web 0.21.3, 2026-10-14).
Rules that must hold
- The web is a target on purpose, with an explicit output. Expo adds web whenever
react-domis installed; either setplatforms: ['ios', 'android'](or removereact-dom,react-native-weband thewebscript), or keepreact-native-web, setweb.outputinapp.config.ts, and record it with the host incontracts/web.json. Default:staticon EAS Hosting. Why: an untested web target ships a crash, and the output decides what runs in Node and which headers reach the host. (web-target,web-contract) - Platform files come in matching pairs. Every
x.web.ts,x.native.ts,x.ios.tsorx.android.tshas a basex.tswith the same extension; a platform route has its base route. Why: Metro tries every platform for.tsbefore.tsx, sox.tsshadowsx.web.tsx; without a base TypeScript resolves nothing and the web cannot resolve a lone.nativefile. (web-platform-pair) - A web file is held to its base. It exports exactly the base's value names and types them with
import type * as Native from './x'(typeof Native.name). Why: every importer is type-checked against the base only; a web file that drifts compiles and throws in the browser. (web-variant-exports,web-variant-typed) - No package that fails in a browser reaches the web bundle.
react-native-google-mobile-ads,expo-tracking-transparency,expo-iap,expo-secure-store,expo-file-system,expo-notifications,react-native/Libraries/*and the contract'snativeOnlyPackagesare imported only by files the web replaces with a.webadapter that tells the truth (unsupported, unavailable). Why: they break the bundle, throw at import, or throw on the first call, and a green export proves none of it. (web-module-support) - No React Native API react-native-web lacks, ignores or answers wrongly. No use of a missing
export (
ToastAndroid,PlatformColor,Settings, ...), missing member (AccessibilityInfo.isBoldTextEnabled,Appearance.setColorScheme,Linking.openSettings,Alert.prompt, ...), silent member (Alert.alert,announceForAccessibility) or wrong member (AccessibilityInfo.isScreenReaderEnabledalwaystrue,I18nManager.isRTL) in the web bundle, unless a guard keeps it off the web: aPlatform.OScheck for web,iosorandroid, a function value under aPlatform.selectkey the web never picks, or atypeoftest (every plain value of aPlatform.selectobject runs on the web). Why: a missing member throwsTypeError(seen in the canonical app), a silent one makes a confirmation vanish, a wrong one takes the wrong branch for every visitor. (web-rn-api) - Browser globals live only in
.webfiles.window,document,navigator,localStorage,sessionStorage,indexedDB,matchMedianever appear in a file iOS and Android load. Why: the tsconfigDOMlib types them everywhere, so tsc accepts code that crashes on a phone. (web-browser-global) - With static or server output, nothing reads the browser at import. A
.webfile reads a browser global inside a function, or behindtypeof window === 'undefined'on the same line. Why: Expo evaluates every module of the web bundle in Node at export time. (web-ssr-scope) - No secret in browser storage. The web sibling of an expo-secure-store adapter keeps the session in
memory or a server-set httpOnly cookie, never
localStorage,sessionStorage, IndexedDB ordocument.cookie. Why: any script on the page reads them. (web-token-storage) - expo-sqlite on the web has its wasm and its isolation headers, in development and deployed.
module.exports = withWebSqlite(config)inmetro.config.js; the host sendsCross-Origin-Embedder-Policy: require-corpandCross-Origin-Opener-Policy: same-origin(the expo-router plugin'sheaderson EAS Hosting, which needsstaticorserveroutput; the host's headers file elsewhere). Nevercredentialless, which Expo's expo-sqlite page shows: Safari and every iOS browser ignore it. Why: without wasm the export fails; without isolation there is noSharedArrayBufferand no storage. (web-sqlite-setup) - Storage waits for the warm-up, and one tab owns it. With expo-sqlite in the web bundle, the
root layout returns
<WebStorageGate>as its outermost element,DirectionGatedirectly inside it (one web step outside app-startup-and-bootstrap's startup order; list the gate incontracts/rtl.jsongateWrappers):warmUpfromuseState(openDeviceWebStorage),onError, and aWebStorageNoticefor each ofinUseElsewhereandunsupported. An adapter the layout reads itself before the gate (the saved language) gets alocalStorage.websibling. Why: the first synchronous call (the direction guard's) times out while the SQLite worker starts, a second tab cannot open the database files, a page without isolation would crash instead of saying so, and a first render that differs from the static HTML is a hydration error. (web-sqlite-warm-up) - The page carries the visitor's direction and language.
src/i18n/direction.web.tskeeps the layout direction on<html dir>(react-native-web'sI18nManageranswers left to right for every language), andsyncDocumentLanguage(language)runs in the language provider's effect. Why: otherwise an Arabic visitor's page reloads, gives up and stays left to right, and screen readers read it in the source language. (web-rn-api,browser-direction) - The error net listens to the window on the web.
app-error-net.web.tsbuilds the canonical net fromerrorandunhandledrejectionevents. Why: there is noErrorUtilsorHermesInternalin a browser, so crashes go unrecorded. (web-error-net) - Every dynamic route has a page or a rewrite. With
staticoutput on a static host, a[param]route exportsgenerateStaticParamsor is listed inclientRouteswith the host's rewrite. Why: a reload of/tasks/t-42is a 404 otherwise. (web-static-routes) - The export renders in a real browser before it ships.
check-web-export.mjs --browser autoserves the export with its headers and opens every smoke route: the testID shows, no console error, no uncaught exception, isolated when expo-sqlite is installed,<html dir lang>right in a right-to-left browser. Why: native modules throw only in a browser; the plain canonical app exported cleanly and crashed on load. (export-*,browser-*)
Workflow
- Decide the target. Read references/web-target-and-output.md
sections 1-3. No web build: set
platforms: ['ios', 'android']and stop (both checkers then printNOT APPLICABLE). A web build: keepreact-native-web, setweb.output, copytemplates/contracts/web.jsonandweb.schema.jsontocontracts/(output, host, smoke routes with the testID each home screen renders). - Run the source checker first to see the gaps:
node ${CLAUDE_SKILL_DIR}/scripts/check-web-support.mjs .. - The root. Copy
templates/src/app/+html.tsx(static or server output) and its testtemplates/test/web/root-html.test.tsx. Copytemplates/src/services/error-net/app-error-net.web.tsandwindow-error-hooks.tswith their tests (error-handling-typed-results owns the net). Copytemplates/src/i18n/direction.web.tsanddocument-language{,.web}.tswith their tests, and callsyncDocumentLanguage(language)in theI18nProvidereffect (references/web-target-and-output.md section 4). - expo-sqlite. Read references/sqlite-on-web.md. Copy
templates/scripts/metro/web-sqlite-metro.tsand wrap the config inmetro.config.js; copytemplates/src/data/expo-sqlite-warm-up-adapter{,.web}.ts,open-device-web-storage.ts,expo-file-system-database-files-adapter.web.ts(persistence-and-migrations' snapshot pruning finds no files on the web) andtemplates/src/features/root-navigator/web-storage-gate.tsxandweb-storage-notice.tsxwith their tests; return the gate outermost in the root layout, aroundDirectionGate, withinUseElsewhereandunsupportednotices, and add it tocontracts/rtl.jsongateWrappers(section 4 of the reference has the layout and the hand-offs; add the four catalog keys). Add therequire-corpisolation headers for the host: references/hosting-and-headers.md. - Modules. For each
web-module-supportline, read references/module-support.md and give the adapter a.web.tssibling in the shape of references/platform-files.md section 5 and examples/share-task-web-adapter.md. A native-only package the table does not know goes intonativeOnlyPackages. - React Native APIs. For each
web-rn-apiline, read references/rn-api-gaps.md: a guard in one of the shapes of its section 6 for a one-line difference, a platform file for more. - Layout and input. Read references/layout-and-input.md; imitate examples/task-row-web.tsx for hover, focus and keyboard.
- Tests. Follow references/testing-web.md section 2: web files are
tested in the unit project by explicit
.webimports; logic stays in plain functions. - Check the source.
npm run typecheck,npx eslint <changed paths> --max-warnings 0, the Jest tests, thennode ${CLAUDE_SKILL_DIR}/scripts/check-web-support.mjs .untilRESULT: PASS(--app-dir apps/mobileinside a monorepo). - Export and smoke.
APP_VARIANT=production EXPO_PUBLIC_APP_VARIANT=production npx expo export --platform web, thennode ${CLAUDE_SKILL_DIR}/scripts/check-web-export.mjs . --browser autountilRESULT: PASS. Read the firstbrowser-consoleline first; export with--devfor readable React errors. - By hand, before a web release: testing-web.md section 5 (resize, keyboard only, a second tab,
deep URL reload, a screen reader, Safari and Firefox, then Safari on a named iOS simulator and
Chrome on a named Android emulator: isolated, no storage notice). Deploying publishes the site:
stop and ask the developer before
eas deployor any host upload (Expo'seas-hostingskill has the commands).
Definition of done
- The app config either leaves web out of
platforms, or setsweb.output, andcontracts/web.jsonrecords the same output, the host and the smoke routes. - Every platform file has a base of the same extension; every
.webfile exports the base's names and types them withimport type * as Native from './<base>'. - No web-bundled import of a failing package; each such adapter has a truthful
.web.tssibling. - No unguarded missing or silent React Native API in the web bundle; no browser global outside
.webfiles; none at module scope with static output. - With expo-sqlite:
withWebSqliteinmetro.config.js,require-corpisolation headers from the host,WebStorageGateoutermost in the root layout (both notices,DirectionGateinside it), and a second tab shows the other-tab notice. -
direction.web.tsanddocument-language{,.web}.tsare in place with their tests, and the language provider callssyncDocumentLanguage(language). -
app-error-net.web.tsandwindow-error-hooks.tsare in place with their tests. -
npm run typecheck, ESLint--max-warnings 0and Jest pass. - The production web export exists:
APP_VARIANT=production EXPO_PUBLIC_APP_VARIANT=production npx expo export --platform web. -
node ${CLAUDE_SKILL_DIR}/scripts/check-web-support.mjs .printsRESULT: PASS. The only SKIP line it may print:SKIP app.config.ts [web-target] not yet created (expo-project-bootstrap creates it). Without a web target it printsNOT APPLICABLE: the app config platforms (ios, android) leave out web(or that react-dom is not installed), and so does the export check. -
node ${CLAUDE_SKILL_DIR}/scripts/check-web-export.mjs . --browser autoprintsRESULT: PASS.
Anti-patterns
- "The export succeeded, so the web works." Native modules return
{}during static rendering and throw only in a browser; run the browser smoke. x.tswithx.web.tsx. Metro loadsx.tson the web and the variant never runs; same extension.- A
Platform.OS === 'web'branch around a native-only import. The import still runs; move it into a platform file. if (typeof window !== 'undefined')in shared code. React Native defineswindowtoo; browser code goes in a.webfile.- Falling back to
localStoragefor a token on the web. Any script reads it; memory or an httpOnly cookie. web.output: 'single'on EAS Hosting with expo-sqlite. No routes manifest, no isolation headers, a crash on load.- Calling
openDatabaseSyncon the first render in the web build without the gate. It times out while the worker starts. - Trusting
AccessibilityInfo.isScreenReaderEnabled()on the web. It always answerstrue; read it only off the web, and design for screen readers there always. - Copying
Cross-Origin-Embedder-Policy: credentiallessfrom Expo's expo-sqlite page. Every iPhone visitor gets no isolation and no storage; sendrequire-corpand give cross-origin resources aCross-Origin-Resource-Policyheader. Alert.alert('Delete task?', ...)for a confirmation. Nothing appears on the web, so nothing is ever confirmed; use the app's dialog.- Publishing source maps with the site, or deploying without asking. Maps go to the crash reporter; a deploy is the developer's call.
Files in this skill
| File | What it is | Read/run when |
|---|---|---|
| references/web-target-and-output.md | Web as a target, the three outputs (what each export writes, measured), the default, +html.tsx on SDK 57, static rendering in Node, dynamic routes, commands, decisions |
Workflow steps 1 and 3 |
| references/platform-files.md | Metro's and TypeScript's resolution, platform routes, the one shape, the typed variant, Platform.OS branches, where variants live, pitfalls |
Workflow steps 2 and 5 |
| references/module-support.md | How each stack package behaves in a browser (measured), the three failure kinds, web adapters, re-verify method | Workflow step 5 |
| references/rn-api-gaps.md | React Native exports and members react-native-web lacks, ignores or answers differently, and what to use instead | Workflow step 6 |
| references/sqlite-on-web.md | expo-sqlite in a browser: wasm, isolation, the warm-up gate, one tab, persistence, the failure ladder | Workflow step 4 |
| references/hosting-and-headers.md | Headers and rewrites per host (EAS Hosting, Netlify, Cloudflare Pages, Vercel, own server), caching, what never ships | Workflow steps 4 and 11 |
| references/layout-and-input.md | Window, styles, hover, keyboard and focus, text, scrolling, links and back, assets, missing feedback | Workflow step 7 |
| references/testing-web.md | Jest recipes for web files, both checkers' rules and options, the browser smoke, checks by hand, the realistic run, limits, debugging | Workflow steps 8-11 |
| examples/share-task-web-adapter.md | Example (shape only): port, native adapter, typed web adapter, fake, test | Workflow step 5 |
| examples/task-row-web.tsx | Example (shape only): a row with hover, focus ring and keyboard behaviour | Workflow step 7 |
templates/src/app/+html.tsx |
The root HTML: language, direction, viewport-fit=cover, scroll reset, ground colour of both schemes |
Workflow step 3 |
templates/test/web/root-html.test.tsx |
The root HTML's test (route folders hold routes only) | Workflow step 3 |
templates/src/services/error-net/ |
app-error-net.web.ts (the net from window events) and window-error-hooks.ts, each with its test |
Workflow step 3 |
templates/src/i18n/ |
direction.web.ts (the layout direction on <html dir>, saved across the direction restart) and direction-web.test.ts; document-language.ts (nothing on devices) and .web.ts (syncDocumentLanguage sets <html lang>) with document-language-web.test.ts |
Workflow step 3 |
templates/src/data/ |
expo-sqlite-warm-up-adapter.ts (no warm-up on devices) and .web.ts (tab lock, async warm-up), each with its test (-web.test.ts for the web file), open-device-web-storage.ts (the seam the root layout calls) with its test; expo-file-system-database-files-adapter.web.ts (lists and removes nothing) with -web.test.ts |
Workflow step 4 |
templates/src/features/root-navigator/ |
web-storage-gate.tsx (holds the root until storage answers, or shows inUseElsewhere / unsupported) and web-storage-notice.tsx (the notice for either reason, outside every provider), each with its test |
Workflow step 4 |
templates/scripts/metro/ |
web-sqlite-metro.ts (withWebSqlite: wasm asset, dev isolation headers) and its test |
Workflow step 4 |
templates/contracts/ |
web.json (output, host, smoke routes, client routes, source maps, native-only packages) and web.schema.json |
Workflow step 1 |
scripts/check-web-support.mjs |
The source checker for rules 1-13 (--help lists the rule ids) |
Workflow steps 2 and 9 |
scripts/check-web-export.mjs |
The export check and, with --browser, the headless Chrome smoke (rules 11 and 14) |
Workflow step 10 |
scripts/selftest.mjs |
Proves both checkers on the fixtures | After changing a checker |
scripts/lib/web-target.mjs |
This skill's helpers both checkers share: app config, web target fact, contract | When changing a checker |
scripts/check-lib.mjs |
Shared script library (CLI, reporting, layout, repo walk, self-test), synced (do not edit here) | Never by hand |
scripts/lib/source-scan.mjs |
Shared source lexer and import helpers, synced (do not edit here) | Never by hand |
assets/web-support-table.json |
The measured package and React Native API table the checker reads | Workflow steps 5-6; re-verify after an upgrade |
assets/shared.json |
Declares the synced files | When adding a shared file |
tests/bases/ |
The small Tasks web app (app/) and export (export/) the fixtures start from |
When adding a rule |
tests/fixtures/ |
Overlay cases: good, one planted bug per rule, passing profiles, bad input | When adding a rule |
Related skills
adaptive-layout- window classes and panes; the browser window resizes freely.architecture-and-boundaries- ports, adapters, the composition root, API routes and server files.persistence-and-migrations- the SqlDriver and data layer the warm-up protects.auth-and-secure-storage- sessions and tokens; the web keeps none in browser storage.error-handling-typed-results- the canonical error net the web variant feeds.app-startup-and-bootstrap- owns the root layout and startup order; the web adds the storage gate outside DirectionGate.accessibility-as-code- the system accessibility store,aria-modal, live regions.rtl-and-direction- ownsdirection.ts, the guard and DirectionGate;direction.web.tskeeps their contract.i18n-strings-and-catalogs-I18nProvider(wheresyncDocumentLanguageruns) and the notice copy.app-variants-and-environment-metro.config.js,EXPO_PUBLIC_*values (public in a web bundle).dependency-management- adding or removingreact-domandreact-native-web.navigation-and-routing,deep-links-and-universal-links- URLs, links and.well-knownfiles.forms-and-input,ui-states-and-feedback- fields, dialogs and toasts that replace native ones.unit-and-component-tests- Jest projects and helpers.crash-reporting-and-observability- source maps and crash reports from the web.skia-canvas-rendering- loading CanvasKit before a canvas renders on the web.- Expo's official
eas-hostingandexpo-domskills - deploy commands; DOM components inside a native app (both in the expo/skills repository, verified 2026-10-16).