How to Build Chrome Extensions with React
Table of content
  1. Chrome Extensions Structure
  2. Chrome Extensions + React (CRA)
  3. The main problem of Chrome Extensions and CRA
  4. Conclusion

Chrome extensions have become a popular way to extend the functionality of many web apps by making them easier to use and more accessible. However, despite how common they are, building them is not so straightforward, especially when you are using a more complex stack with modern web frameworks such as React.

Chrome Extensions Structure

To understand how to build chrome extensions with React (specifically CRA), we first need to grasp the typical structure of a Chrome extension. An extension consists of these elements: background script, content scripts, popup (html + js), manifest.json.

Chrome Extension structure (from Chrome Developers)
  1. Background script – the extension’s core, which runs in its own environment (or ‘context’). It’s active and running as long as your browser is open and the extension is enabled.
  2. Content scripts – scripts that are injected into websites and run in their context. They are written using JavaScript and hence can do whatever JS can – change the styles, inject html, send requests etc. We can control whether they run in all websites you visit, or only on specific URLs.
  3. popup.html is the html file that is opened by default in the popup window when you click on the extension’s icon. Like any html file, it can have CSS and JavaScript attached to it.
  4. manifest.json – the main source of truth for the browser, here is where all the specifications of our extension are, such as name, version, icon etc. Here is also where we write the paths to our content and background scripts, and where the main problem with React and CRA occurs. We will get to this next.

Chrome Extensions + React (CRA)

Let’s start by creating a new CRA project. We will build an extension that injects a popup onto a webpage on the click of a button inside the extension’s main popup.

npx create-react-app chrome-extensions-react-demo

In order to run a React app both in the main popup and the current webpage, we will need to technically create two React applications: one that gets rendered inside the popup.html file, and one in the context of the webpage.

Let’s start with the popup.html. To get this part to work, we just have to ensure CRA’s index.html is correctly added in the manifest.json in the ‘default_popup’ field. I have renamed it to popup.html to clearly distinguish it.

manifest.json

We also need to edit the manifest.json to fit the chrome extension structure. We’ll add a manifest version property to the file with the value ‘2’ (the currently supported version), the app version, and icons.

Create React App by default adds an inline script to the html file, which in chrome extensions will cause errors in the console. To disable this behaviour, we have to add this line in the .env file:

INLINE_RUNTIME_CHUNK=false

Let’s adjust the popup’s styling since it looks a bit wonky with the default styles. We need to keep in mind that the maximum size of the popup window is 600px height by 800px width.

html, body, #root {
  height: 200px;
  width: 200px;
}

body {
  margin: 0;
  font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen',
    'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue',
    sans-serif;
  -webkit-font-smoothing: antialiased;
  -moz-osx-font-smoothing: grayscale;
}

code {
  font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New',
    monospace;
}
index.css

 

.App {
  text-align: center;
  height: 100%;
}

.App-logo {
  height: 40vmin;
  pointer-events: none;
}

@media (prefers-reduced-motion: no-preference) {
  .App-logo {
    animation: App-logo-spin infinite 20s linear;
  }
}

.App-header {
  background-color: #282c34;
  height: 100%;
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  font-size: calc(10px + 2vmin);
  color: white;
}

.App-link {
  color: #61dafb;
}

@keyframes App-logo-spin {
  from {
    transform: rotate(0deg);
  }
  to {
    transform: rotate(360deg);
  }
}
Popup.css

Now, to preview our application we need to run the build command and load the build folder into the browser on the extensions panel. Unfortunately, we will need to redo this every time we make a change in the app.

npm run build

This command will have added a /build directory in your app’s main directory.

Go to chrome://extensions/ in your browser, turn on ‘Developer mode’ and click load unpacked. Navigate to your app’s folder and select the /build directory.

Chrome extensions panel

We can now see the app in the taskbar.

Now go to any website and click on the icon. The popup should open with the default CRA app. Hooray!

The main extension popup.

Now we want to add to our app the ability to inject React code into pages. To do that, we will create a second React app to inject into visited pages. Let’s restructure the app a bit to easily separate the two React apps by putting our existing files into src/popup.

Next let’s add a contentScript directory under /src, and create two files in there: index.js – where we will be mounting the app into the DOM, and ContentScript.js which will be the main, top-level React component.

This is what the our new structure should look like.

To ensure that the injected app’s scripts and styles are isolated and do not conflict with the current webpage’s, we will wrap it in a web component. In the index file, instead of mounting the app component on the div with the id of ‘root’, we will pack the app into a web component and mount it as a direct child of the webpage’s html document. For this to work you will need to install @webcomponents/custom-elements.

npm install @webcomponents/custom-elements

For the modal component I will be using MaterialUI with JSS, since they both work well in the web component shadow DOM environment and don’t require much extra reconfiguration.

import React from "react";
import ReactDOM from "react-dom";
import "@webcomponents/custom-elements";
import ContentScript from "./ContentScript";
import { StylesProvider, jssPreset } from "@material-ui/styles";
import { create } from "jss";

class ReactExtensionContainer extends HTMLElement {
  connectedCallback() {
    const mountPoint = document.createElement("span");
    mountPoint.id = "reactExtensionPoint";

    const reactRoot = this.attachShadow({ mode: "open" }).appendChild(
      mountPoint
    );

    const jss = create({
      ...jssPreset(),
      insertionPoint: reactRoot,
    });

    ReactDOM.render(
      <StylesProvider jss={jss}>
        <ContentScript />
      </StylesProvider>,
      mountPoint
    );
  }
}

const initWebComponent = function () {
  customElements.define("react-extension-container", ReactExtensionContainer);

  const app = document.createElement("react-extension-container");
  document.documentElement.appendChild(app);
};

initWebComponent();
index.js

This is our initial code for mounting the web component wrapper for the content script React application.

Let’s now add the actual ContentScript modal that will be injected into the page.

import { useState } from "react";
import { makeStyles } from "@material-ui/styles";
import { Box, Button, Typography } from "@mui/material";

const useStyles = makeStyles({
  overlay: {
    position: "fixed",
    top: 0,
    right: 0,
    bottom: 0,
    left: 0,
    background: "rgba(0, 0, 0, 0.3)",
    zIndex: 999999999,
  },
  modal: {
    position: "absolute",
    top: "50%",
    left: "50%",
    transform: "translate(-50%, -50%)",
    background: "white",
    borderRadius: 10,
    padding: 40,
  },
});

function ContentScript() {
  const { modal, overlay } = useStyles();
  const [open, setOpen] = useState(false);

  chrome.runtime.onMessage.addListener((message) => {
    if (message.value === "openPopup") {
      setOpen(true);
    }
  });

  if (!open) return null;

  return (
    <Box className={overlay}>
      <Box className={modal}>
        <Typography>Popup</Typography>
        <Button variant="contained" onClick={() => setOpen(false)}>
          Close
        </Button>
      </Box>
    </Box>
  );
}

export default ContentScript;
ContentScript.js

Let’s run build again to update our build files.

npm run build

To make sure the app content script is run by the browser, we need to add it in the manifest.json as a content script with a specific path. But if we look into the build folder we previously generated, we will see that there is no main javascript file to load, only a bunch of chunks and we can’t tell which is the popup app and which is the injected app.

The main problem of Chrome Extensions and CRA

As we know, CRA is built using webpack, which is configured to bundle our application’s code and generate chunks of JavaScript which are then loaded by the application. This way of chunking code results in randomly generated files whose names change every time we run the build command. Let’s recall the structure of chrome extensions: we have content and background scripts, and a manifest.json. In the manifest, we pass paths to the scripts to tell the browser which files it should load. The problem is, the manifest is a static json file so we have no way to dynamically tell the names of the JavaScript chunks we want to load. To fix this problem, we need to reconfigure webpack in a way that it will generate JavaScript files with non-changing names. And to do that we need to eject out of CRA.

npm run eject

This command will create a /config directory and there expose webpack configuration files to us.

We need to change two files, ‘paths.js’ and ‘webpack.config.js’ to disable chunking and tell webpack what to name the files.

  appHtml: resolveApp('public/popup.html'),
  appIndexJs: resolveModule(resolveApp, 'src/popup/index'),
  appContentJs: resolveModule(resolveApp, 'src/contentScript/index'),
Add popup and content index js files – paths.js, lines 59-61
Add popup and content index js files
 entry: {
      popup:
        isEnvDevelopment && !shouldUseReactRefresh
          ? [       
              webpackDevClientEntry,
              paths.appIndexJs,
            ]
          : paths.appIndexJs,
      background: paths.appBackgroundJs,
      content: paths.appContentJs,
    },
Define popup and content entry points – webpack.config.js, lines 172-200
Define popup and content entry points
    output: {
      // The build folder.
      path: isEnvProduction ? paths.appBuild : undefined,
      // Add /* filename */ comments to generated require()s in the output.
      pathinfo: isEnvDevelopment,
      // There will be one main bundle, and one file per asynchronous chunk.
      // In development, it does not produce real files.
      filename: isEnvProduction
        ? 'static/js/[name].js'
        : isEnvDevelopment && 'static/js/bundle.js',
      // TODO: remove this when upgrading to webpack 5
      futureEmitAssets: true,
      // There are also additional JS chunk files if you use code splitting.
      chunkFilename: isEnvProduction
        ? 'static/js/[name].js'
        : isEnvDevelopment && 'static/js/[name].chunk.js',
Define output, remove the [contenthash] part from the generated file names – webpack.config.js lines 208-215
Remove the [contenthash] part from the generated file names
  splitChunks: false,
Disable code chunking – webpack.config.js line 302
 runtimeChunk: false,
Disable code chunking – webpack.config.js line 306
Disable code chunking
 plugins: [
      // Generates an `index.html` file with the <script> injected.
      new HtmlWebpackPlugin(
        Object.assign(
          {},
          {
            inject: true,
            template: paths.appHtml,
            filename: 'popup.html',
          },
          isEnvProduction
            ? {
                excludeChunks: [
                  'background',
                  'content', 
                ],
Pass popup.html as name and exclude content from being injected into popup.html – webpack.config.js, lines 558-571
Pass popup.html as name and exclude content from being injected into popup.html
     const entrypointFiles = entrypoints.popup.filter(
            fileName => !fileName.endsWith('.map')
          );
Rename main into popup (since that’s what we called the main entrypoint) – webpack.config.js, lines 657-659
Rename main into popup (since that’s what we called the main entrypoint)

Whew, that was quite a bit!

This way we can control how and where our JavaScript is output, so we can keep our file paths static.

The last thing we have to do now is add the new static js file paths to our content script field in the manifest.json and the related permissions shown below.

{
  "name": "React App",
  "description": "Create React App Sample",
  "version": "1.0",
  "manifest_version": 2,
  "browser_action": {
    "default_popup": "popup.html",
    "default_icon": "logo192.png",
    "default_title": "Open the popup"
  },
  "icons": {
    "16": "logo192.png",
    "48": "logo192.png",
    "128": "logo192.png"
  },
  "background": {
    "scripts": ["static/js/background.js"]
  },
  "content_scripts": [
    {
      "matches": ["<all_urls>"],
      "js": ["static/js/content.js"]
    }
  ],
  "optional_permissions": ["<all_urls>"],
  "permissions": ["tabs", "activeTab"]
}
manifest.json

We can now build our React application and run it as a content script. Yay!

Let’s add one more thing: a background script.

To do that, we’ll follow the previous steps of adding a new subfolder src/background with an index.js file for our background script to live in.

To allow communication between all the different extension parts and scripts, the chrome extension API provides a messaging functionality. We can send a message from the content script to the background script, execute some logic in the background script, and send (or not) a response back to the content script.

chrome.runtime.onMessage.addListener((message, sender) => {
  chrome.tabs.sendMessage(sender.tab?.id, {
    value: message.value + " + " + "hello from background script",
  });
});
We’ll just grab the message text and add ” + hello from background” to it before sending it back.

We can then intercept the message within the content script and for example display it in the frontend.

chrome.runtime.onMessage.addListener((message) => {
  setMessage(message.value);
  if (message.value === "openPopup") {
    setOpen(true);
  }
});
We can get the message from the listener and display it with useState.

We’ll have to adjust the webpack configuration to exclude the background script from chunking too.

appBackgroundJs: resolveModule(resolveApp, 'src/background/index'),
Add background/index.js to paths.js – line 61
Add background/index.js to paths.js
background: paths.appBackgroundJs,
Add background path in webpack config. – line 198
Add background path in webpack config.
isEnvProduction
  ? {
    excludeChunks: [
    'background',
    'content', 
  ],
Exclude background script from being injected into popup.html – webpack.config.js, line 571
Exclude background script from being injected into popup.html

The difference between a background script and a context script is that content scripts run within the context of a webpage in one tab – so, if you have many tabs open, you might be running multiple instances of the content script, one in each tab. The background script on the other hand runs as one background process in the browser, no matter how many tabs you have open.

Conclusion

Building chrome extensions with React can be tricky and difficult to debug, but we have tackled the main problems successfully. We have managed to build a chrome extension using React by doing one major thing: ejecting out of CRA and changing the webpack configuration. Of course the solution given here is just one of many – you’re welcome to go ahead and adjust it to suit your needs specifically.

After you are done with building and testing your application, you can publish the extension on Chrome Web Store, where it will undergo an official review before going live.

Project code: https://github.com/teacodeio/chrome-extension-article

Product Owner at TeaCode