React Native2025-03-08|7 min read

Using Vercel with Expo and React Native Web for Universal Apps

The promise of React Native has always been "learn once, write anywhere" — but for years, web support felt like a second-class citizen. In 2025, that has changed meaningfully. Expo's web support is stable, React Native Web is mature, and Vercel's deployment experience is seamless enough that I now ship several apps that share a single codebase across iOS, Android, and the web. Here's everything I've learned about making that architecture work in production.

The Universal App Concept

A universal app maintains one JavaScript and TypeScript codebase that compiles to native iOS, native Android, and a web application. The components, business logic, API calls, state management, and navigation structure are shared. Only the truly platform-specific behaviour — camera access, push notifications, native gestures — diverges into platform-specific files.

This is different from a responsive web app with a companion mobile app. The code is the same. A bug fix, a new feature, an API change — it propagates to all three platforms in a single pull request. For small teams or solo developers maintaining a product across surfaces, this is transformative.

Expo Web and React Native Web

Expo's web support is powered by React Native Web, a library by Nicolas Gallagher (now at Meta) that implements the React Native component and API surface on top of the DOM. View becomes a div, Text becomes a span, Image becomes an img, and StyleSheet compiles to CSS classes. The mapping is not perfect — complex native animations and some platform APIs have no web equivalent — but the vast majority of UI and logic translates cleanly.

Expo uses Metro (or optionally Webpack via @expo/webpack-config) to bundle the web output. Starting from Expo SDK 50, the default web bundler switched to Metro with web support, which aligns the bundling experience with native and dramatically improves build times.

Setting Up a React Native Project for Web

If you're starting fresh, create an Expo project with web support included:

npx create-expo-app@latest my-universal-app
cd my-universal-app
npx expo install react-native-web react-dom @expo/metro-runtime

For an existing Expo project, add the web dependencies and ensure your app.json includes a web configuration:

// app.json
{
  "expo": {
    "web": {
      "bundler": "metro",
      "output": "static",
      "favicon": "./assets/favicon.png"
    }
  }
}

Setting "output": "static" tells Expo to generate a static HTML export — critical for Vercel deployment and SEO. Run npx expo export --platform web to verify the build produces a dist/ directory before you touch any deployment configuration.

Configuring Vercel for Expo Web Builds

Vercel's zero-configuration deployment works well with Expo's static export. Connect your repository, and set the following in your Vercel project settings:

  • Build Command: npx expo export --platform web
  • Output Directory: dist
  • Install Command: npm install (or yarn install)
  • Node.js Version: 20.x

Alternatively, add a vercel.json at the project root to version-control the build configuration:

{
  "buildCommand": "npx expo export --platform web",
  "outputDirectory": "dist",
  "framework": null,
  "rewrites": [
    { "source": "/(.*)", "destination": "/index.html" }
  ]
}

The rewrite rule is essential for single-page app routing — without it, directly navigating to a deep link like /profile/settings returns a 404 because the file doesn't exist at that path in the static output. The rewrite ensures all routes serve index.html and React Navigation handles the rest client-side.

Platform-Specific Code

Not every API works on every platform. React Native provides two clean mechanisms for platform-specific code that I use regularly.

The first is Platform.select, which is suitable for small inline differences:

import { Platform, StyleSheet } from 'react-native';

const styles = StyleSheet.create({
  container: {
    paddingTop: Platform.select({
      ios: 44,
      android: 24,
      web: 0,
    }),
    cursor: Platform.select({ web: 'pointer', default: undefined }),
  },
});

The second is file-based platform extensions, which are better for larger divergences. If you create UserAvatar.web.tsx alongside UserAvatar.tsx, Metro and Webpack will automatically import the .web.tsx file when bundling for web. I use this for anything that requires a fundamentally different implementation — a date picker that should use a native modal on mobile but an <input type="date"> on web, for example.

Navigation on the Web

React Navigation v6+ has solid web support. URL-based routing, browser history integration, and linking configuration all work with the same API used for native deep linking. The key is configuring the linking prop on your NavigationContainer:

const linking = {
  prefixes: ['https://myapp.com', 'myapp://'],
  config: {
    screens: {
      Home: '',
      Profile: 'profile/:userId',
      Settings: 'settings',
    },
  },
};

<NavigationContainer linking={linking}>
  {/* your navigators */}
</NavigationContainer>

With this in place, navigating to https://myapp.com/profile/123 in a browser opens the Profile screen with userId set to '123' — the same URL that would deep-link into the native app. The parity between web and native routing is one of my favourite aspects of this architecture.

SEO Considerations for React Native Web Apps

A purely client-side React Native Web app has limited SEO potential — search engines see an empty HTML shell until JavaScript loads. For content that needs to rank, you have two options.

The first is static export with pre-rendered pages, which Expo's "output": "static" mode provides. For a portfolio, marketing site, or blog built on this stack, static export gives you HTML that search engines can index directly without executing JavaScript.

The second is adding <head> metadata. Expo supports this via expo-head (part of Expo Router) or the react-native-helmet-async package, which writes <title>, <meta name="description">, and Open Graph tags into the document head:

import { Stack } from 'expo-router';

// Using Expo Router's built-in head management
<Stack.Screen
  options={{
    title: 'My Profile',
    // On web, this sets the document title and meta tags
  }}
/>

Vercel Edge Functions with Expo API Routes

Expo Router's API routes feature — introduced in SDK 50 — lets you write server-side endpoints that co-locate with your app code. When deployed to Vercel, these become serverless functions or edge functions automatically. I use this for form submissions, webhooks, and lightweight data fetching that shouldn't happen client-side:

// app/api/contact+api.ts
export async function POST(request: Request) {
  const { name, email, message } = await request.json();
  await sendEmail({ name, email, message });
  return Response.json({ success: true });
}

Vercel detects the API routes in the dist output and provisions the appropriate serverless infrastructure. The endpoint is available at /api/contact, callable from both the web version of your app and the native app. One codebase, one deployment, both surfaces served.

Real-World Use Case: A Portfolio That Shares Mobile Code

My own portfolio is built on exactly this stack. The web version deploys to Vercel and serves static HTML with proper meta tags. The same codebase, with a few platform-specific files for native features, builds to an Expo Go-compatible app. Shared components include the project cards, the contact form logic, the skills section, and all the theme definitions. Only the navigation chrome and a handful of native-specific interactions diverge.

The practical benefit is that I maintain one design system, one set of components, and one test suite. When I update my project list or change a colour, I push once and all three targets — iOS, Android, web — are updated on the next build. For a solo developer, this is an enormous quality-of-life improvement over maintaining parallel codebases.

The combination of Expo's mature web support, React Native Web's faithful component mapping, and Vercel's frictionless deployment has made universal apps a realistic production choice rather than an aspirational one.

If you're building a new product in 2025 and you know it needs both a mobile app and a web presence, starting with this universal architecture costs almost nothing extra upfront and saves significant effort as the product grows. The tooling is there, the deployment story is smooth, and the developer experience has never been better. Start with a new Expo project, get the web export working locally, connect it to Vercel, and you'll be shipping to three platforms before the end of the day.