Adding web support to a React Native project in 2023
Tags: reactnative, react, webdev
Published: 2023-01-11 20:36:46

# Adding web support to React Native in 2023

Hi! I'm going to go through the process of adding web support to a React Native project in 2023. The year doesn't really matter, but there's a lot of older information out there so having a reference in time can help.  My goal is to remain as close as possible to the structure of a bare React Native project created with the standard `react-native init` command. I am also going to try and "touch" as few files as possible from the React Native project so that it's easier to use the [React Native Upgrade Helper](https://react-native-community.github.io/upgrade-helper/) in the future to upgrade React Native versions.

There are two obvious paths to go down to accomplish adding web support. The first option is to use a package named `react-scripts`. This is what the `create-react-app` command that is widely used in the React world uses under the hood. It bundles up webpack and the entire build and packaging pipeline and hides it behind a few nice scripts. While it works pretty great out of the box, unfortunately when it comes time to change the webpack or babel configs, for example, it makes it really hard to do so. And trust me, there will come a time when you'll need to do that and then you'll be kicking yourself for going with this option.

The other downside is that `react-scripts` expects all your app's source code to be in a `src/` folder, and by default that's not how a React Native project is setup. It's not too hard to make the changes to keep react-scripts happy, but it involves moving a ton of files around, and when it comes time to upgrade React Native, that's going to cause tears.

Soo....we are going to roll our own setup. It's really pretty straight forward.

# Create a new project

It's always easier to do surgery on a brand new project, but these instructions should work for any React Native project. I chose to use the typescript template, but if you're not into types, feel free to use plain javascript. Oh, and don't forget to replace `myproject` with a name of your choosing!

**Update**
As of React Native 0.71, typescript is the default for new projects.  You don't need to add the `--template react-native-template-typescript` flag anymore.

Typescript:

```js
npx react-native init myproject
```

Javascript:
There's no default template that uses Javascript anymore.  The default typescript template is configured to continue working with Javascript, so just renamed any .tsx files to .jsx (for example, `App.tsx` -> `App.jsx`).

## Add the dependencies

We only need to add two dependencies (and a whole bunch of dev dependencies). The `react-dom` dependency's version should match the version of the `react` version that `react-native init` chose for us. Open up `package.json` and check what version is being used. As of the writing of this blog and using React Native 0.71.0, the version of React is 18.1.0.

```bash
yarn add react-native-web react-dom@18.1.0
```

And now for the dev dependences

```bash
yarn add -D webpack webpack-cli webpack-dev-server html-webpack-plugin react-refresh @pmmmwh/react-refresh-webpack-plugin file-loader url-loader babel-loader babel-plugin-react-native-web @types/react-dom
```

I'm not a big fan of adding random dependencies copied out of a blog post, so I'm going to go through each one and explain what it does.

- **react-native-web** the magical dependency that allows us to use the React Native API on the web.
- **react-dom** this adds support for "webpages" to React. Since we scaffolded out a project with `react-native init`, we need to add this.

- **webpack** This is a Javascript bundler. It's going to take the dozens/hundreds/thousands of individual javascript files and bundle them up into a nice set of files. It does a ton of other stuff too. React Native uses the metro bundler to accomplish the same thing on the native side. We'll end up with two bundlers working their magic in our project (metro for iOS/Android, and webpack for the web).
- **webpack-cli** This is a CLI for webpack, used so it's possible to call webpack from package.json scripts (more on this later).
- **webpack-dev-server** This is a plugin for webpack that starts up a web server so we can see the output of the code as it's being worked on. This works in conjunction with `react-refresh`.
- **html-webpack-plugin** This injects the javascript bundle (that webpack creates) into a script tag in the index.html file
- **react-refresh** This reacts to changes in our code and "recompiles" it on the fly so we get to nearly instantly see the changes as soon as save is hit.
- **@pmmmwh/react-refresh-webpack-plugin** Plugin for webpack so `react-refresh` and `webpack` know how to play nicely together
- **file-loader url-loader babel-loader** These three dependencies are "loaders" for webpack. A loader lets webpack know how to handle certain types of files as well as use other utilities (such as babel) as a build step
- **babel-plugin-react-native-web** This is a babel plugin that will help keep the bundle size down by stripping out unused react-native-web components. It's also going to create an alias from 'react-native' to 'react-native-web' so we can still import react-native components like normal on the web
- **@types/react-dom** This is just types for react-dom and is option if not using typescript

## Create some directories and files

In a React Native project, you have `ios` and `android` folders to hold the native specific files and configurations. Guess what? We're going to make a `web` folder (and a public subfolder)!

```
mkdir -p web/public
```

## Web support in index.js

In a React Native project, index.js is the _entry_ file. This means the bundler (webpack or metro) will open this file first, and work it's way through the tree of imports until the app is completely bundled.

The way metro (React Native's bundler for native platforms) and webpack (bundler for the web) work are a bit different, so we need to add some web specific code to index.js. In React (for web), you will have a html file, usually index.html, that has something like `<div id="root" />` in it. React will find that `<div>` with the specific id (in this case "root") and replace it with our app.

**NOTE**: React recently changed how to initialize the root element (for React version 18.0.0 and higher). If you are using React 17.x, it's going to be a bit different.

**For React > 18.0.0**  
Somewhere near the top of `index.js`, add these imports:

```js
import {createRoot} from 'react-dom/client';
import {Platform} from 'react-native';
```

and then at the bottom of the file, after the `AppRegistry.registerComponent(appName, () => App);` line, add this block. This will check if the platform is web, and then tell it which element id React should look for in the index.html file and then to render our app where that `<div>` is.

```js
if (Platform.OS === 'web') {
  const root = createRoot(document.getElementById('root'));
  root.render(<App />);
}
```

**For React < 18 (17.x.x and below)**  
Somewhere near the top of `index.js`, place the following:

```js
import {Platform} from 'react-native';
```

At the bottom of index.js, place the following:

```js
if (Platform.OS === 'web') {
  AppRegistry.runApplication('App', {
    rootTag: document.getElementById('root'),
  });
}
```

## Create the index file in web/public/index.html

As I mentioned above, React (on the web) needs an index.html file that contains an element with a specific id. React will replace that element with our rendered app. All it technically needs is the `<div id="root" />` tag as well as the small CSS in the `<head>` tag that lets `react-native-web` be "full screen".

In `web/public/index.html` place the following code:

```html
<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="utf-8" />
    <meta name="viewport" content="width=device-width,initial-scale=1" />
    <title>React Native Web</title>
    <meta name="description" content="Your app description" />
    <link rel="icon" href="/favicon.svg" type="image/svg+xml" />
    <link
      rel="alternate icon"
      href="/favicon.ico"
      type="image/png"
      sizes="16x16" />
    <link rel="apple-touch-icon" href="/apple-touch-icon.png" sizes="180x180" />
    <link rel="mask-icon" href="/favicon.svg" color="#FFFFFF" />
    <meta name="theme-color" content="#ffffff" />
    <style>
      html,
      body {
        height: 100%;
      }
      body {
        overflow: hidden;
      }
      #root {
        display: flex;
        height: 100%;
      }
    </style>
  </head>
  <body>
    <noscript> You need to enable JavaScript to run this app. </noscript>
    <div id="root"></div>
  </body>
</html>
```

## Webpack config

Now we just need to create the webpack config in `web/webpack.config.js`. Webpack can be difficult (or impossible!) to master, but there are a few key concepts that can make reasoning about it much easier. Webpack is a bundler, so it takes a bunch of code, images and files from different sources and concatenates it all into one file (which can then be split into chunks to keep the size managable). It accomplishes this by using _loaders_, which are sort of like a plugin that's designed to manage different file types. In the config below we have three loaders, a babel loader, an url loader and a file loader. The url loader is setup to handle images, the file loader is handling fonts, and the babel loader is handing all javascript/typescript code. A loader can simply copy a file from one place to another (such as the loader handling fonts below) or it can transform that asset before handing it back to webpack to bundle. In the case of the babel loader, it's actually transpiling our code into a format we have defined in `babel.config.js`. In an effort to keep the build systems of the native side (metro) and the webside (webpack) in sync, the babel loader is actually importing `babel.config.js` but adding in the `react-native-web` babel plugin. This means that if you add a babel plugin to `babel.config.js`, both the native and web build systems will pick it up.

```js
const path = require('path');
const webpack = require('webpack');
const HtmlWebpackPlugin = require('html-webpack-plugin');
const ReactRefreshWebpackPlugin = require('@pmmmwh/react-refresh-webpack-plugin');

const appDirectory = path.resolve(__dirname, '../');

const babelConfig = require('../babel.config');

// Babel loader configuration
const babelLoaderConfiguration = {
  test: /\.(tsx|jsx|ts|js)?$/,
  exclude: [
    {
      and: [
        // babel will exclude these from transpling
        path.resolve(appDirectory, 'node_modules'),
        path.resolve(appDirectory, 'ios'),
        path.resolve(appDirectory, 'android'),
      ],
      // whitelisted modules to be transpiled by babel
      not: [],
    },
  ],
  use: {
    loader: 'babel-loader',
    options: {
      cacheDirectory: true,
      // Presets and plugins imported from main babel.config.js in root dir
      presets: babelConfig.presets,
      plugins: ['react-native-web', ...(babelConfig.plugins || [])],
    },
  },
};

// Image loader configuration
const imageLoaderConfiguration = {
  test: /\.(gif|jpe?g|png|svg)$/,
  use: {
    loader: 'url-loader',
    options: {
      name: '[name].[ext]',
      esModule: false,
    },
  },
};

// File loader configuration
const fileLoaderConfiguration = {
  test: /\.(woff(2)?|ttf|eot|svg)(\?v=\d+\.\d+\.\d+)?$/,
  use: [
    {
      loader: 'file-loader',
      options: {
        name: '[name].[ext]',
        outputPath: 'fonts/',
      },
    },
  ],
};

module.exports = argv => {
  return {
    entry: path.resolve(appDirectory, 'index'),
    output: {
      clean: true,
      path: path.resolve(appDirectory, 'web/dist'),
      filename: '[name].[chunkhash].js',
      sourceMapFilename: '[name].[chunkhash].map',
      chunkFilename: '[id].[chunkhash].js',
    },
    resolve: {
      extensions: [
        '.web.js',
        '.js',
        '.web.ts',
        '.ts',
        '.web.jsx',
        '.jsx',
        '.web.tsx',
        '.tsx',
      ],
    },
    module: {
      rules: [
        babelLoaderConfiguration,
        imageLoaderConfiguration,
        fileLoaderConfiguration,
      ],
    },
    plugins: [
      // Fast refresh plugin
      new ReactRefreshWebpackPlugin(),

      // Plugin that takes public/index.html and injects script tags with the built bundles
      new HtmlWebpackPlugin({
        template: path.resolve(appDirectory, 'web/public/index.html'),
      }),

      // Defines __DEV__ and process.env as not being null
      new webpack.DefinePlugin({
        __DEV__: argv.mode !== 'production' || true,
        process: {env: {}},
      }),
    ],
    optimization: {
      // Split into vendor and main js files
      splitChunks: {
        cacheGroups: {
          commons: {
            test: /[\\/]node_modules[\\/]/,
            name: 'vendor',
            chunks: 'initial',
          },
        },
      },
    },
  };
};
```

## Add some package.json scripts

Add these scripts to the scripts section of `package.json`. Running `yarn web` will start up the dev server and running `yarn web:build` will build a production version and place it into `web/dist`.

```js
"web": "webpack-dev-server --config ./web/webpack.config.js --mode development",
"web:build": "webpack --config ./web/webpack.config.js --mode production",
```

## Replace App.tsx (or App.js) with something

By default, React Native will give you a `App.tsx` (or `App.js`) that has a "demo" page in it. There are some components not supported on the web. This is going to contain your app eventually, so just go ahead and replace the entire default App.[tsx/jsx] file's contents with something like below.

```js
import React from 'react';
import {View, Text} from 'react-native';

const App = () => {
  return (
    <View
      style={{
        flex: 1,
        justifyContent: 'center',
        alignItems: 'center',
        backgroundColor: 'purple',
      }}>
      <Text>Hello, world!</Text>
    </View>
  );
};

export default App;
```

## Tada!

That's it! We added some dependencies, created a `web` folder with some files in it but ended up only modifying two already existing files, `index.js` and `package.json`. This means when it comes time to upgrade version of React Native, it's going to be much, much simpler.

If you haven't upgraded before, the process is basically looking at a diff between your current project's version and the version you are upgrading to. You then upgrade all the differences by hand. Since we've only slightly modified two files from the default project, this process is going to be as painless as possible.

Go and run `yarn web` and the dev server should start up on `http://localhost:8080`. Make some changes and see the react-refresh server in action. For extra fun, run `yarn web`, `yarn ios`, and `yarn android` all together and see how cool it is to make a change and see it update on all three platforms at once! I'm not responsible if your computer starts on fire, though!