Home
About
Writings
Writings
← Writings
13 July 2021

Build a cross browser web extension with React and Parcel


This blog post is a side effect of me having built a web extension for a side project recently (guess it wasn't a pure project ¯\_(ツ)_/¯ ). Since React is usually my tool of choice when tackling anything web and I also wanted my smol project to reach maximum possible users, there were three requirements from the setup phase:

  • Handle rendering in React (Popup and OptionsUI)
  • Inject a React app through content script
  • Make it available on Chrome, Edge and Firefox

While digging the internet, I found a lot of useful resources on how to use Webpack to build cross browser extensions or about using React to build extensions targeting a particular browser but couldn't really find anything matching all of my requirements.

If you want to get right into the code instead of reading, you can find the GitHub repo to the boilerplate here.

It is 2021 and it has become easier than ever to build cross browser extension thanks to ✨Parcel✨. It is magic and you better believe it. It automatically validates and bundles your web extension and even gives you hot module reloading out of the box.

The file structure

├── assets [For all images]
├── manifest.json
├── package.json
├── .parcelrc [Parcel config]
└── src
    ├── components [common compnents to be used across react apps]
    │
    ├── pages [The entry point for different react apps in the extension]
    │   ├── Content [App to be injected to content page using]
    │   │   └── index.js
    │   ├── Popup [App for popup]
    │   │   └── index.js
    │   └── Options [App for options]
    │       └── index.js
    │
    ├── scripts
    │   ├── background.js [Background Script]
    │   └── content.js [Inject react app and other operations]
    │
    └── static [HTML entry point for different part of the extension]
        └── popup.html
        └── options.html

First things first

Let's setup the manifest.json , package.json and .parcelrc before starting with project files.

manifest.json

{
  "manifest_version": 2,
  "name": "Web Extension",
  "description": "Web extension which can be shipped to multiple browsers.",
  "version": "0.0.1",
  "icons": {
    "16": "assets/icon16.png",
    "32": "assets/icon32.png",
    "48": "assets/icon48.png",
    "128": "assets/icon128.png"
  },
  "browser_action": {
    "default_icon": "assets/icon128.png",
    "default_popup": "src/static/popup.html"
  },
  "content_scripts": [
    {
      "matches": ["https://*/*"],
      "js": ["src/scripts/content.js"]
    }
  ],
  "background": {
    "scripts": ["src/scripts/background.js"],
    "persistent": false
  },
  "options_ui": {
    "page": "src/static/options.html",
    "open_in_tab": true
  }
}

The manifest.json is the entry point for the parcel bundler. It'll check the relative paths given in the manifest and figure out if you're using React, Vue or Typescript automatically and bundle it accordingly. Told you it is magic. Btw, the paths given here all match the project structure we decided beforehand.

package.json

Initialise your package.json and install react and react-dom as dependencies and the latest version of parcel and @parcel/config-webextension as dev-dependencies

yarn init
yarn add react react-dom
yarn add -D parcel@next @parcel/config-webextension@nightly

NOTE: Parcel 2 is still in beta at the time of writing this and things may break. Some versions of @parcel/config-webextension@nightly have given me errors while building. I've locked the versions which worked for me in package.json below but you can experiment with the latest versions available.

The parcel web extension documentation recommends adding the following to your package.json for the best development experience (HMR and source maps)

{
  "targets": {
    "webext-dev": {
      "sourceMap": {
        "inline": true,
        "inlineSources": true
      }
    },
    "webext-prod": {}
  },
  "scripts": {
    "start": "parcel manifest.json --host localhost --target webext-dev",
    "build": "parcel build manifest.json --target webext-prod"
  }
}

It should finally look similar to this:

{
  "name": "react-parcel-web-extension",
  "version": "0.0.1",
  "description": "Boilerplate code for building web extensions using parcel",
  "author": "Soumik Sur",
  "license": "ISC",
  "scripts": {
    "start": "parcel manifest.json --host localhost --target webext-dev",
    "build": "parcel build manifest.json --target webext-prod"
  },
  "dependencies": {
    "react": "^17.0.2",
    "react-dom": "^17.0.2"
  },
  "devDependencies": {
    "@parcel/config-webextension": "2.0.0-nightly.2312",
    "parcel": "2.0.0-beta.3.1"
  },
  "targets": {
    "webext-dev": {
      "sourceMap": {
        "inline": true,
        "inlineSources": true
      }
    },
    "webext-prod": {}
  }
}

Add .parcelrc to your root folder with the following configuration to enable bundling web extension.

{
  "extends": "@parcel/config-webextension"
}

The extension!

Now that we are done with the setup, let's get to the actual extension files. The four components we will be covering in this extensions setup are the background script and the three react apps for popup, options and content script.

Background Script

The background#scripts array in manifest.json points to your background script(s) for your extension. You can use any flavour of javascript in there as parcel will transpile it anyway.

Injecting react app into the DOM

All of the React apps reside in src/Pages folder in our project structure. Just import the App and use ReactDOM to render it onto a div you created and appended to the DOM.

(In my usecase I had to inject a button which opened a modal for extension settings accessible from the website I was targeting)

// scripts/content.js

import React from "react";
import ReactDOM from "react-dom";

import App from "../pages/Content";

const newDiv = document.createElement("div");
newDiv.setAttribute("id", "content-app-root");
document.body.appendChild(newDiv);
ReactDOM.render(<App />, newDiv);

Popup and Options UI

The browser_action#default_popup field in manifest.json points to a HTML file which imports the react app from src/Pages/Popup and similarly the options_ui#page field points to a HTML file which imports it's respective react app from src/Pages/Options.

Here's an example with the Popup UI and it's the process with Options UI.

<!-- src/static/popup.html -->

<html>
  <head>
    <title>Extension Popup</title>
  </head>
  <body>
    <div id="root"></div>
    <script src="../pages/Popup/index.js"></script>
  </body>
</html>
//src/Pages/Popup/index.js

import React from "react";
import ReactDOM from "react-dom";
import Text from "../../components/text";

const App = () => {
  return (
    <div style={{ minWidth: 300, minHeight: 500 }}>
      <Text>This is a test popup</Text>
    </div>
  );
};

ReactDOM.render(<App />, document.getElementById("root"));

With this, you should have a skeleton of a web extension built with react which works on multiple browsers. yarn start and load the dist folder into chrome/firefox/edge to get started with your project!

Miscellaneous

TypeScript

I followed javascript in this write up but adding typescript to this is as easy as changing all the .js files to .ts and adding a tsconfig.json in the project root. The boilerplate repo has a separate branch with the TypeScipt setup.

Linting

Linting is usually very opinionated so I am not going to impose my preferences onto anybody. However a little tip I picked up while working on my side-project was that you can add webextensions under env in your eslint config to use the chrome extension APIs freely without eslint complaining about it.

// .eslintrc.json

{
  // ...rest of your eslint config
  "env": {
    "es6": true,
    "browser": true,
    "webextensions": true
  }
}
Twitter