TL;DR
- Yes, Expo for Web can power large apps when you’re building SPAs, dashboards, or internal tools with shared native code.
- For SEO-critical, SSR-heavy, or edge-rendered web experiences, pair Expo (native) with a Next.js web app that uses react-native-web and shared UI packages.
- The sweet spot: share components and business logic across platforms, but don’t force web-specific needs through native constraints.
What “Expo for Web” actually is
Expo for Web runs your React Native code on the web via react-native-web, mapping core primitives (View, Text, Pressable) to DOM/CSS. You get:
- One codebase for iOS, Android, and Web
- Expo modules (where web support exists) and unified tooling
- File-based routing with Expo Router (SPA on web)
What you do not get by default:
- Server-side rendering (SSR) or static site generation (SSG)
- Advanced web bundling features you may expect from Next.js (e.g., built-in image optimization, RSC features)
When Expo Web is a good fit
- Internal tools, admin dashboards, B2B apps
- Authenticated SPAs where SEO is not critical
- Microfrontends that can be embedded as SPAs
- Teams optimizing for maximum code sharing with mobile
When to pair with Next.js (or keep web separate)
- SEO, social previews, and fast first contentful paint via SSR/SSG/ISR
- Edge rendering, middleware, and complex CDN cache strategies
- Large teams that need advanced web-only ergonomics (RSC, image/fonts pipelines)
Pattern options:
- Single app (Expo only): fastest to ship, SPA web.
- Monorepo: Expo (native) + Next.js (web) sharing UI and logic via packages.
Minimal working example (Expo Web SPA)
This shows a tiny Expo Router app that runs on iOS, Android, and Web.
// File: app/_layout.tsx
import { Stack } from 'expo-router';
export default function Layout() {
return <Stack />;
}
// File: app/index.tsx
import { View, Text, Pressable, StyleSheet } from 'react-native';
import { useState } from 'react';
export default function Home() {
const [count, setCount] = useState(0);
return (
<View style={styles.container}>
<Text accessibilityRole="header" style={styles.title}>Hello, Web + Native</Text>
<Text>Count: {count}</Text>
<Pressable onPress={() => setCount(c => c + 1)} style={styles.btn}>
<Text style={styles.btnText}>Increment</Text>
</Pressable>
</View>
);
}
const styles = StyleSheet.create({
container: { flex: 1, alignItems: 'center', justifyContent: 'center', gap: 12 },
title: { fontSize: 20, fontWeight: '600' },
btn: { backgroundColor: '#2563eb', paddingHorizontal: 12, paddingVertical: 8, borderRadius: 6 },
btnText: { color: 'white', fontWeight: '600' },
});
Run on web: npx expo start --web
Quickstart
A) Build a large SPA with Expo only
- Create app: npx create-expo-app@latest --template tabs
- Add Expo Router if not present: npx expo install expo-router react-native-safe-area-context react-native-screens
- Configure Entry: add "main": "expo-router/entry" in package.json
- Start web: npx expo start --web
- Scale with shared modules, env configs, and platform-specific files (.web.tsx, .native.tsx)
B) Monorepo: Expo (native) + Next.js (web)
Goal: Keep mobile in Expo, get SSR/SSG in Next.js, and share UI via react-native-web.
- Create a monorepo (pnpm/yarn workspaces). Structure:
- apps/mobile (Expo)
- apps/web (Next.js)
- packages/ui (shared RN components)
- In packages/ui, write RN components only using core RN APIs.
// packages/ui/src/Button.tsx
import { Pressable, Text, StyleSheet } from 'react-native';
export function Button({ title, onPress }: { title: string; onPress: () => void }) {
return (
<Pressable onPress={onPress} style={styles.btn}>
<Text style={styles.txt}>{title}</Text>
</Pressable>
);
}
const styles = StyleSheet.create({
btn: { backgroundColor: '#16a34a', paddingHorizontal: 14, paddingVertical: 8, borderRadius: 6 },
txt: { color: 'white', fontWeight: '600' },
});
- In apps/web (Next.js), configure react-native-web aliases and transpilation.
// apps/web/next.config.js
/** @type {import('next').NextConfig} */
const nextConfig = {
reactStrictMode: true,
transpilePackages: [
'react-native',
'react-native-web',
'expo',
'expo-modules-core',
'packages/ui',
],
webpack: (config) => {
config.resolve.alias = {
...(config.resolve.alias || {}),
'react-native$': 'react-native-web',
};
config.resolve.extensions = [
'.web.tsx', '.web.ts', '.web.jsx', '.web.js',
...config.resolve.extensions,
];
return config;
},
};
module.exports = nextConfig;
// apps/web/babel.config.js (Next will switch to Babel if present)
module.exports = { presets: ['babel-preset-expo'] };
// apps/web/app/page.tsx (Next.js App Router)
'use client';
import { Button } from 'packages/ui/src/Button';
import { View, Text } from 'react-native';
export default function Page() {
return (
<View style={{ padding: 24 }}>
<Text accessibilityRole="header">Next.js + react-native-web</Text>
<Button title="Click" onPress={() => alert('Hello')} />
</View>
);
}
- In apps/mobile (Expo), consume the same UI package and run npx expo start.
- Deploy Next.js for web (SSR/SSG/ISR) and keep Expo for native releases.
Performance notes
- Component choice: Prefer core primitives (View, Text, Pressable). Avoid heavy DOM wrappers or web-only CSS tricks inside shared components.
- Styles: Use StyleSheet.create for better RNW performance; avoid inline objects in tight loops.
- Lists: Use FlatList/SectionList with getItemLayout when possible; keep item components pure.
- Code-splitting: Use dynamic import on web routes or large modules.
- Images: On Expo-only web, pre-size images and cache aggressively. In Next.js, use its image pipeline.
- Animations: Simple transitions are fine. Complex gestures/physics may perform worse on web; test Reanimated/Gesture Handler on target browsers.
- Bundle health: Track bundle size; prune unused Expo modules and avoid large polyfills.
Common pitfalls
- Module support: Not all native Expo modules have web implementations. Guard by Platform.OS checks or provide fallbacks.
- Browser APIs: Node-specific APIs (fs, path) aren’t available. Prefer Web APIs or conditional dependencies.
- Accessibility: React Native semantics differ from semantic HTML. Validate roles/labels on web.
- Routing: Expo Router gives SPA routing on web. If you need SSR routes, use Next.js.
- CSS expectations: RN styles are not CSS; don’t rely on selectors or layout features that don’t exist in RN (e.g., grid) inside shared code.
- Build tooling: Mixed transpilation across packages can cause resolution issues. Ensure consistent TS targets and that shared packages are transpiled.
How to decide for a “big” project
- If SEO/SSR is a requirement, plan for Next.js on web from day one; still share 60–90% of UI/logic.
- If your product is primarily authenticated app surfaces (dashboards, forms, feeds), Expo Web alone is often sufficient and faster to maintain.
- Validate critical libraries: charts, maps, editors. Prefer RNW-compatible libraries or isolate them behind web-only components.
- Prove it with a spike: build one complex screen and measure time-to-interactive, bundle size, and dev velocity before committing.
Tiny FAQ
- Does Expo Web support SSR? Not out of the box. Use Next.js (or another SSR framework) for web SSR/SSG.
- Can I reach 100% code sharing? Rarely. Expect some .web/.native splits for routing, images, and platform quirks.
- Is performance comparable to React DOM apps? Often close for typical app UIs; test heavy animations, huge lists, and complex editors.
- Can I use Tailwind or CSS-in-JS? Yes, but in shared RN components prefer RN styles; apply web-only styling in web-specific files.
Bottom line
Expo for Web is “good enough” for many large SPAs and internal apps, especially when code sharing with mobile is a priority. For SEO/SSR-centric web products, pair Expo (native) with a Next.js web app, sharing a unified React Native component library across platforms.