In this article:
We'll dive into the essential components of a Chrome Extension, explaining how these parts interact to form a functioning extension.
We'll discuss how tools like React can improve our extension development process, simplifying the creation of dynamic interfaces and easing interactions with browser APIs.
Guiding you through a hands-on experience, we'll build a simple Chrome Extension step by step, enhancing your understanding of the concepts and equipping you with practical skills in extension development.
React is a renowned framework for creating robust web applications. It is one of the most popular and is supported by millions of developers who create incredible tools for it. As a developer, I love it, and I know the community shares this sentiment. So, why wouldn't we use it for more than just regular web apps, like browser extensions, for instance? That's what occurred to me when I needed to create one. I had an array of company-wide tools at my disposal and several team members skilled in React who could help maintain it alongside me. So, it was the obvious choice.
Unified development approach in the team: React, MobX, Redux, you name it - we're all on the same page. It's not just about using the same tools, it's about building a shared understanding. React, especially, brings a lot to the table. Its component-based structure is like a gift for browser extensions, allowing us to create reusable parts that ensure consistency and efficiency. What's more, React's virtual DOM works magic with updating and rendering components. It's all about keeping things smooth and speedy.
Reusability of tools: UI Kit, utility functions, Storybook - it's all about making the most of what we have. It's not just about efficiency, but consistency too. Thanks to React's wide community and rich ecosystem, we're never short of solutions or advice for common challenges. This combination speeds up our development process and makes it a lot more fun.
Simplicity of maintenance: With our unified approach and reusable tools, maintenance is a breeze. If you're a front-end developer with a handle on these technologies, you can jump right in. React's learning curve is more of a gentle slope than a steep hill, making it easier for new team members to get on board.
Flexibility: One of the things I love about React is that it doesn't lock you into a specific way of doing things. It gives us the freedom to choose the best tools and libraries for our browser extensions. It's all about finding what works best for us.
Let’s make a short overview of what’s inside an extension.
The extension consists of the following main parts: popup
, content scripts
, and service worker
(formerly a background script), with properties described in the manifest.json
file.
Let's have a look at each component in order.
This is a required file for every extension, describing its basic properties such as its name, description, required permissions, and scripts in use. Later we will look at a specific example.
This is the visible part of the extension, the window that opens when you click on the icon in the browser. Essentially, this is a regular web application created using the same tools as any other frontend. Here we will implement our popup using React.
The pop-up starts running when the icon is clicked and stops execution when closed, so it doesn’t keep any state between openings.
"action": {
"default_popup": "index.html"
},
Some browser extension APIs are available in the popup, for example, subscribing to messages from other scripts.
runtime.onMessage.addListener((message: Message) => {
if (message.from === Participant.Background) {
// Do your cool stuff
}
if (message.from === Participant.Content) {
// Do your nice stuff
}
})
popup.ts
Background or Service Worker.
This script operates independently in the background, irrespective of what's happening with browser tabs. It's akin to the backbone of a web application, taking care of various tasks such as managing HTTP requests, data storage, redirects, and authentication.
But remember, if these features aren't necessary for your specific needs, engaging a service worker is completely optional.
One thing to note with the current version of manifest v3, a service worker can interrupt its execution. Therefore, it might not be the best place for storing any state, unlike the practice with background scripts in earlier versions.
"background": {
"service_worker": "./static/js/background.js"
},
manifest.json
Content scripts serve as an important component of browser extensions as they operate within the context of a webpage. These scripts have direct access to the Document Object Model (DOM), which is the data representation of the objects that comprise the structure and content of a document on the web. This access provides a broad scope for interaction within the page, including manipulating the webpage's structure, style, and content. Thus, these scripts can be seen as the bridge between the webpage and the extension, allowing for effective interaction and functionality. The specific pages where these content scripts should run are defined in the manifest, offering fine-grained control over where an extension activates and what functionality it provides.
Using Chrome APIs to enhance the functionality of an extension often requires specific permissions. These permissions are declared in the manifest and express the extension's intent to access certain data or APIs.
Let’s have a look at the manifest fields:
permissions - this category contains a list of APIs that the extension needs to function properly. These permissions need to be granted before the extension is used, ensuring the user is aware of the data and features the extension will access.
optional_permissions - these permissions are very similar to the main permissions
category, but they are requested dynamically during runtime. This means the extension can ask for these permissions as needed, rather than all at once during installation. This can help build trust with users by only requesting access when necessary and providing a clear reason why.
host_permissions - these permissions involve match patterns that provide access to specific hosts or websites. This allows the extension to interact with webpages on these hosts, expanding its reach and functionality.
optional_host_permissions - similar to host_permissions
, these permissions are requested during runtime. This allows the extension to request access to additional hosts or websites as needed, rather than requiring all permissions upfront.
I’ll be using Yarn as a package manager. However, you can use whatever you prefer.
Installation of Yarn globally:
sudo npm i --g yarn
We’ll be using TypeScript and CRA.
TypeScript is a superset of JavaScript that adds static type definitions. Types provide a way to describe the shape of an object, providing better documentation, and allowing TypeScript to validate that your code is working correctly. This results in robust, well-structured, and more maintainable code. TypeScript's static typing catches errors during development rather than when the code is running. TypeScript provides better autocompletion and helps in code editors, which speeds up development and reduces the chance of errors. Its static types make refactoring more straightforward and safer.
Create React App is a popular tool for creating new React applications without having to manually configure tools like Webpack or Babel. It allows us to focus on the code and not on the setup. CRA comes with a preconfigured setup that includes a web server for development, a testing environment, and scripts for building and deploying your application. It also eliminates the need to spend time setting up and configuring the development environment. CRA uses sensible defaults and best practices for React development.
Let’s initialize our React project with CRA and TypeScript template
yarn create react-app my-extension --template typescript
Next, we’ll the webextension-polyfil and its associated types.
The webextension-polyfill
is a handy tool that we'll be using in our project. Its primary role is to let us use the WebExtension API in Chrome and provide associated types. This API is pretty much the building blocks of our browser extension, enabling us to interact with the browser's functionality.
But here's the thing, not all browsers have the same level of support for the WebExtension API. This is where webextension-polyfill
comes into play. It's kind of like a translator, making sure our extension can speak the same language as Chrome, regardless of the original API compatibility.
What this means for us is that we can write our code once and have it work across multiple browsers, saving us from the headache of dealing with browser-specific quirks. It standardizes the API syntax, bringing consistency to our development process. Plus, it provides TypeScript definitions which helps to ensure our code is robust and error-free.
In other words, webextension-polyfill
is a major time-saver and helps keep our code clean and efficient. It lets us focus on building great features for our extension, rather than getting caught up in browser compatibility issues.
Adding this to our project:
yarn add webextension-polyfill
yarn add -D @types/webextension-polyfill
yarn add -D @types/chrome
Let’s e create a file structure for all necessary parts of the extension
src/
├── background/
│ ├── index.ts
├── content/
│ ├── index.ts
├── popup/
└── public/
└── manifest.json
Then we’ll add some basic code just to check it’s running once we launch the extension.
import { runtime } from 'webextension-polyfill'
runtime.onInstalled.addListener(() => {
console.log('[background] loaded ')
})
export {}
background/index.ts
console.log('[content] loaded ')
export {}
content/index.ts
We need to move the files created by CRA App.*
to src/popup
.
src/popup/index.tsx
becomes the entry point of the Popup.
In src/index.ts
, we add an import of Popup so that the build
command works without additional modifications.
import './popup/index'
src/index.ts
Now, our small React application has become an extension Popup.
Now we have the following structure:
src/
├── background/
│ ├── index.ts
├── content/
│ ├── index.ts
├── popup/
│ ├── App
│ │ ├── App.css
│ │ ├── App.test.css
│ │ └── App.tsx
│ ├── index.tsx
└── public/
└── manifest.json
To build our code, we need to adjust Webpack which is responsible for bundling under the bonnet of CRA. For this, we install two libraries: react-app-rewired
and customize-cra
. The latter is essentially a layer built on top of the former, allowing us to modify the Webpack configuration that comes with Create React App.
yarn add -D customize-cra react-app-rewired
echo > config-overrides.js
We need to adjust two parameters within the Webpack configuration:
entry
- this is the initial point from which Webpack starts building the dependency tree. At this stage, we specify paths to the three main components of the extension: popup, background service worker, and content scripts.
const overrideEntry = (config) => {
config.entry = {
main: './src/popup', // the extension UI
background: './src/background',
content: './src/content',
}
return config
}
output
- this defines the paths and names of the files that will be created as a result of the build.
// ...
const overrideOutput = (config) => {
config.output = {
...config.output,
filename: 'static/js/[name].js',
chunkFilename: 'static/js/[name].js',
}
return config
}
We end up with this config-overrides.js
file:
const { override } = require('customize-cra')
const overrideEntry = (config) => {
config.entry = {
main: './src/popup', // the extension UI
background: './src/background',
content: './src/content',
}
return config
}
const overrideOutput = (config) => {
config.output = {
...config.output,
filename: 'static/js/[name].js',
chunkFilename: 'static/js/[name].js',
}
return config
}
module.exports = {
webpack: (config) => override(overrideEntry, overrideOutput)(config),
}
config-overrides.js
Basically, we just told Webpack to take these files from here and save the processed result there.
An essential file for an extension manifest.json
describes its main properties. Let's create a basic configuration for our extension.
{
"manifest_version": 3,
"name": "My Extension",
"version": "1.0.0",
"description": "A simple Chrome extension with React.",
"icons": {
"16": "logo192.png",
"48": "logo192.png",
"128": "logo192.png"
},
"background": {
"service_worker": "./static/js/background.js"
},
"content_scripts": [
{
"matches": ["<all_urls>"],
"js": ["./static/js/content.js"]
}
],
"action": {
"default_popup": "index.html"
},
"permissions": ["storage", "tabs"]
}
manifest.json
Let's have a look at its contents:
manifest_version
- this refers to the version of the manifest file format that the extension adheres to. The current latest version is 3. It's essential to set this correctly as the various versions have differing capabilities and requirements.
name, description
- these fields define the public-facing name and a brief summary of your extension's functionality. They're what users will see in the Chrome Web Store and the Extensions Management page in the browser.
icons
- this section denotes the extension's icons in various sizes. These icons appear in multiple places: the Chrome Web Store, the Extensions Management page, and the toolbar button if the extension has one.
background
- if your extension maintains a long-term state, or performs long-term operations, you can include a background script here. The service_worker
field points to a JavaScript file that the browser will keep running as long as it's needed.
content_scripts
- a set of scripts that get injected into the pages that match the specified patterns. These scripts can read and manipulate the DOM, and are isolated from the scripts loaded by the page itself. The matches
field specifies which pages the scripts should be injected into.
action, default_popup
- the default_popup
field inside the action
field is used to specify the HTML file that will be rendered inside the popup when the toolbar button is clicked. This is a primary way of interacting with users.
permissions
- this section is for requesting access to various browser features that aren't available to web pages. For instance, the storage
permission allows the extension to use the chrome.storage
API to store and retrieve data that persists across browser sessions, and the tabs
permission gives the extension the ability to interact with browser tabs. It's important to note that users will see a warning when the extension requests these permissions.
The created manifest file should be placed in src/public
, from where Webpack will take it to the root of the extension build.
Now we can execute the build with the command I added to the package.json
→ scripts
:
"scripts": {
"build": "INLINE_RUNTIME_CHUNK=false react-app-rewired build",
}
Setting INLINE_RUNTIME_CHUNK=false
when building your app with Create React App is important in the context of Chrome Extensions because of the Content Security Policy (CSP).
CSP is a security feature that helps prevent certain types of attacks, including Cross Site Scripting (XSS) and data injection attacks. It works by specifying rules for the types and sources of assets that a webpage can load. Chrome extensions have a pretty strict CSP that, among other things, disallows inline scripts in most cases.
When Create React App builds the project for production, by default, it includes the Webpack runtime script inline in the index.html
file. That's done to optimize performance by saving a network request, but it violates the CSP of a Chrome extension. Therefore, we need to set INLINE_RUNTIME_CHUNK=false
to make Create React App put the runtime script into a separate file rather than inlining it, thus ensuring it doesn't violate the CSP.
So, let’s build and have a look.
The resulting content of the build folder can already be installed in Chrome.
To do this, enable Developer mode
in chrome://extensions/
and click Load unpacked
.
The browser performs manifest validation, and if everything is okay, it installs the extension.
Further, upon updating the code, the build needs to be executed again, but the extension doesn't need to be reinstalled. Just clicking the Refresh
button is enough.
Now, you may notice that the popup is quite tiny and hardly has room for any meaningful interface.
To enlarge it, let's add a couple of CSS properties for the main page.
body {
width: 300px;
height: 600px;
}
// ...
The limit for a popup size is 600px in height and 800px in width.
Let’s imagine a simple functionality for our extension that could demonstrate the basic concepts in action. It will highlight how key parts of an extension work together, like the content scripts, background scripts, and the popup UI.
So, the task we’re solving:
Steps:
Add a click listener in the content script.
Send the data to the service worker upon an event.
Store the data in the browser storage with a "tab id - number" pair.
To start with, we'll make a very simple counter with the result stored in a local variable.
console.log('[content] loaded ')
// add a naive counter
let count = 0
src/content/index.ts
Next, we'll write a function to add a listener to the global window
object. We'll also declare a type for the event listener.
// ...
type Listener = (event: MouseEvent) => void
function registerClickListener(listener: Listener) {
window.addEventListener('click', listener)
}
src/content/index.ts
We'll then create a function to increment the counter:
// ...
function countClicks() {
count++
console.log('click(): ', count)
}
src/content/index.ts
Finally, we need to call this function when the script loads.
src/content/index.ts
// ...
export function init() {
registerClickListener(countClicks)
}
init()
src/content/index.ts
Let's see how this works in the browser.
Each time you click on a tab, the console outputs the number of clicks.
You can also access the service worker console from the chrome://extensions/
page, clicking on service worker
link.
Background
In the next step, we're configuring the content script to dispatch a message to the background script each time there's a click.
import { runtime } from 'webextension-polyfill'
console.log('[content] loaded ')
type Listener = (event: MouseEvent) => void
let count = 0
function registerClickListener(listener: Listener) {
window.addEventListener('click', listener)
}
function countClicks() {
count++
console.log('click(): ', count)
return runtime.sendMessage({
from: 'content',
to: 'background',
action: 'click'
}
}
export function init() {
registerClickListener(countClicks)
}
init()
src/content/index.ts
However, it's important to note that if you attempt to invoke this event prematurely, an error will surface.
content.js:1 Uncaught (in promise) Error: Could not establish connection.
Receiving end does not exist.
The cause of this issue lies in the absence of a receiver for these messages.
To fix this, we need to establish a receiver in our background script src/background/index.ts
import { runtime } from 'webextension-polyfill'
type Message = {
from: string
to: string
action: string
}
export function init() {
// the message receiver
runtime.onMessage.addListener((message: Message) => {
if (message.to === 'background') {
console.log('background handled: ', message.action)
}
})
console.log('[background] loaded ')
}
runtime.onInstalled.addListener(() => {
init()
})
src/background/index.ts
Subsequently, we need to identify the origin of the message, i.e., the tab from which it was sent. To achieve this, we will utilize the tabs object.
// ...
import { tabs } from 'webextension-polyfill'
async function getCurrentTab() {
const list = await tabs.query({ active: true, currentWindow: true })
return list[0]
}
export function init() {
runtime.onMessage.addListener(async (message: Message) => {
if (message.to === 'background') {
console.log('background handled: ', message.action)
const tab = await getCurrentTab()
const tabId = tab.id
}
})
console.log('[background] loaded ')
}
// ...
src/background/index.ts
When we capture the tabId
and its associated clicks, we need to store this data somewhere. However, the service worker, which might be a place to consider for storage, isn't always running and can stop unexpectedly, potentially losing data.
So, we need a more stable place to store data. This is why we use storage.local
the web extension API. This storage option is designed to hold onto data reliably, even when the service worker isn't active. By using storage.local
, we can securely store and manage the tabId
and click data, ensuring our extension works properly.
import { runtime, storage, tabs } from 'webextension-polyfill'
type Message = {
from: string
to: string
action: string
}
async function getCurrentTab() {
const list = await tabs.query({ active: true, currentWindow: true })
return list[0]
}
async function incrementStoredValue(tabId: string) {
const data = await storage.local.get(tabId)
const currentValue = data?.[tabId] ?? 0
return storage.local.set({ [tabId]: currentValue + 1 })
}
export async function init() {
await storage.local.clear()
runtime.onMessage.addListener(async (message: Message) => {
if (message.to === 'background') {
console.log('background handled: ', message.action)
const tab = await getCurrentTab()
const tabId = tab.id
if (tabId) {
return incrementStoredValue(tabId.toString())
}
}
})
}
runtime.onInstalled.addListener(() => {
init().then(() => {
console.log('[background] loaded ')
})
})
src/background/index.ts
Now we're ready to work on the popup and show the information we've collected. This will give users a clear view of the data through an easy-to-use interface.
We'll need to use the getCurrentTab
function again, so let's move it into a helper file for better code organization and reuse.
import { tabs } from 'webextension-polyfill'
export async function getCurrentTab() {
const list = await tabs.query({ active: true, currentWindow: true })
return list[0]
}
src/helpers/tabs.ts
Then we’ll create a React component to display our click counter.
export const Counter = () => {
const value = 0
return (
<div
style={{
height: '100vh',
fontSize: '24px',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
}}
>
Clicks: {value}
</div>
)
}
src/popup/Counter/index.tsx
Currently, it simply displays zero clicks in the middle of the popup window.
So, the next step is to fetch the click count from storage
using the same API as we did in the service worker file.
import { useEffect, useState } from 'react'
import { storage } from 'webextension-polyfill'
import { getCurrentTab } from '../../helpers/tabs'
export const Counter = () => {
const [value, setValue] = useState()
useEffect(() => {
const readBackgroundMessage = async () => {
const tab = await getCurrentTab()
const tabId = tab.id
if (tabId) {
const data = await storage.local.get(tabId.toString())
const currentValue = data?.[tabId] ?? 0
setValue(currentValue)
}
}
readBackgroundMessage()
}, [])
return (
<div
style={{
height: '100vh',
fontSize: '24px',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
}}
>
Clicks: {value}
</div>
)
}
src/popup/Counter/index.tsx
After rebuilding our extension, reloading the extension and refreshing the page, it's time to test our work.
Now it shows exactly what we needed, the number of clicks for each individual tab!
Check it out.
All the code shared in this article can be found in this repo: https://github.com/CheerfulYeti/browser-extension-example
We've taken a good look at how to put together a single-page app (SPA) with React and package it as a Chrome Extension using Manifest V3. Starting from basic setup to the development of a simple click counter feature, we navigated through crucial elements such as message management between different parts of an extension, the application of web storage, and the appropriate handling of service workers in the context of a Chrome extension.