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
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
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
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
andwebpack
know how to play nicely togetherfile-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:
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.
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:
import {Platform} from 'react-native';
At the bottom of index.js, place the following:
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:
<!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.
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
.
"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.
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!
Top comments (2)
Best Current, Latest and Updated Tutorial this year.
Thanks bro
Unfortunately I got some errors, it does not work