DEV Community

Mike Hamilton
Mike Hamilton

Posted on • Updated on

Adding web support to a React Native project in 2023

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 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:

npx react-native init myproject
Enter fullscreen mode Exit fullscreen mode

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.

yarn add react-native-web react-dom@18.1.0
Enter fullscreen mode Exit fullscreen mode

And now for the dev dependences

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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:

import {createRoot} from 'react-dom/client';
import {Platform} from 'react-native';
Enter fullscreen mode Exit fullscreen mode

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.

if (Platform.OS === 'web') {
  const root = createRoot(document.getElementById('root'));
  root.render(<App />);
}
Enter fullscreen mode Exit fullscreen mode

For React < 18 (17.x.x and below)

Somewhere near the top of index.js, place the following:

import {Platform} from 'react-native';
Enter fullscreen mode Exit fullscreen mode

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

if (Platform.OS === 'web') {
  AppRegistry.runApplication('App', {
    rootTag: document.getElementById('root'),
  });
}
Enter fullscreen mode Exit fullscreen mode

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:

<!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>
Enter fullscreen mode Exit fullscreen mode

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.

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',
          },
        },
      },
    },
  };
};
Enter fullscreen mode Exit fullscreen mode

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.

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

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.

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;
Enter fullscreen mode Exit fullscreen mode

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!

Top comments (0)