React apps as Chrome extensions

Recently, I had a reason to build a Chrome extension for the first time in years. Since it needed to have some non-trivial UI, I wanted to build it with a modern framework like React. Chrome extensions (all browser extensions really — they share a standard now) are built with the core web platform pillars of HTML, CSS and JavaScript, meaning you can basically implement them however you want, but there are some quirks compared to regular web apps.

The most obvious quirk is that there’s no server delivering your app; a Chrome extension’s code lives and runs entirely on the client device. This frees you from many of today’s concerns associated with the latency, slowness, volatility and expense of delivering lots of code over the network. But these concerns also drive a lot of the built-in behaviour of popular “meta-frameworks” like Next.js and Gatsby — server-side rendering, lazy loading — that generally make these tools great choices for a web app. Sensing I might spend a lot of time fighting the defaults, I chose Create React App instead.

Here’s how to get off the ground with the standard template (TypeScript flavour):

npx create-react-app my-chrome-extension --template typescript
cd my-chrome-extension
npm run build

Now you have a React app in the build directory and can run it locally with npm start.

Getting to Hello World

There are many aspects you can have in a Chrome extension. Here, we’ll focus on the popup - this is the actual UI that’ll be shown when a user clicks your extension icon. To tell Chrome about the popup, you’ll need to declare it in the manifest.json.

{
"name": "my-chrome-extension",
"description": "It does something cool!",
"version": "0.0.1",
"manifest_version": 3,
"action": {
"default_popup": "build/index.html",
"default_title": "My Chrome Extension"
}
}

With that done, you can load the extension in Developer Mode and hit the icon to show the popup, which shows…a small white square. This doesn’t look very promising, but it’s only a couple of tweaks away from working.

When doing a production build, react-scripts inlines some JavaScript in the index.html that helps bootstrap the app faster. However, Chrome extension popups have a special content security policy that disallows inline scripts, so your React app is shut down before it can even get going. Fortunately, this optimisation can be turned off:

// package.json
{
"scripts": {
"start": "react-scripts start",
"build": "react-scripts build",
"build": "INLINE_RUNTIME_CHUNK=false react-scripts build",
"test": "react-scripts test",
"eject": "react-scripts eject"
}
}

The other issue is with the paths to the JS and CSS files which, if you check the console for the extension popup, you’ll see are not being found. Like most modern frameworks, react-scripts outputs root-based paths for style and script imports (e.g. /static/js/main.1228eada.js), which is a smart default based on various assumptions including a web server, client-side routing and distinct “pages”, none of which are applicable to this context. Again, there’s a handy escape hatch, which outputs relative paths instead (e.g. static/js/main.1228eada.js):

// package.json
{
"private": true,
"homepage": ".",
"dependencies": {
}

As you might expect, this has implications if you actually want to add routing; we’ll get to that later. Anyway, if you try the extension icon again now, you’ll see the coveted React starter app in there.

Using Chrome APIs

At some point, you’ll want your popup to interact with other parts of your extension, which is done with message passing. To make this easy to work with, you can isolate your calls to Chrome APIs in targeted helper functions that live outside your components and hooks:

export function getThing(): Promise<string> {
if (process.env.NODE_ENV === 'production') {
return new Promise<string>((resolve) => {
chrome.runtime.sendMessage({ type: 'THING' }, (response) => resolve(response))
})
}
return Promise.resolve('Example thing!')
}

The conditional on NODE_ENV === 'production' means that when your app has been built and is running in the extension, it’ll call the Chrome API, and when developing locally with npm start it’ll hit your stub instead. Helpfully, the production build will strip out the condition meaning your development-only stubs won’t end up in the extension bundle.

Having the interactions with Chrome isolated like this also means you can mock them when writing tests for your app using jest.mock.

Routing

As your extension’s functionality grows, you might want to spread the UI out across multiple views, and instinctively start setting up routing in your app. Here’s a refresher on how routing generally works on single-page apps:

  • You have one index.html which loads the app
  • The web server will serve up that one index.html regardless of the path in the URL
  • The app uses the path as the source of truth for which components to render
  • The app will unmount and rerender as needed when the path changes (e.g. a link is clicked) without a window-level navigation

Not for the first time, this isn’t really applicable to the Chrome extension context. There’s no web server, and the URL of your popup will be something like:

chrome-extension://nomnigokoajgkahpaabcdandnpeijbjp/build/index.html

This doesn’t seem like something we should be messing with or relying on. However, we can still get the benefit from our familiar tools and patterns by using the “hash” flavour of routing, which only uses the hash portion of the URL and leaves the actual path alone.

import {
HashRouter as Router,
Route,
Routes
} from 'react-router-dom';

function App() {
return (
<Router>
<div className="App">
<Routes>
<Route path="/" element={<Main/>}/>
<Route path="/about" element={<About/>}/>
</Routes>
</div>
</Router>
);
}

This works nicely, although it’s worth bearing in mind that this flavour of routing is considered legacy — originating from before browsers widely supported the History API — and could fall out of support at some point.

Packaging

To get your extension uploaded to the Chrome store, you’ll need have it in a zip file, so it makes sense to automate this. Since you’re not doing anything too complex, you can handle it in a simple npm script with the help of bestzip:

npm install --save-dev bestzip 
// package.json
{
"scripts": {
"start": "react-scripts start",
"build": "INLINE_RUNTIME_CHUNK=false react-scripts build",
"test": "react-scripts test",
"eject": "react-scripts eject",
"zip": "bestzip extension.zip assets/* build/* background.js content.js manifest.json"
},
}

Wrapping up

That’s it! There’s tons more to extensions than I’ve touched on here, but hopefully this’ll help you work React into the places you want it when you build your next one.

My code and configuration examples above are abridged in places, so I put an example project on GitHub that fully demonstrates the approach.