Skip to content

Create a Chrome Extension With React, Typescript, and Tailwindcss

This article was inspired by Creating a Chrome Extension with React a Step by Step Guide.

For the full code, check out the repo.

Use the official Create React App CLI to kickstart your project:

Terminal window
npx create-react-app react-chrome-ext --template typescript
cd react-chrome-ext

Update App.tsx to show a simple string:

function App() {
return (
<div className="App">
Hello World
</div>
);
}
export default App;

Update index.tsx:

import React from 'react';
import ReactDOM from 'react-dom/client';
import App from './App';
import "./App.css";
const root = document.createElement("div")
root.className = "container"
document.body.appendChild(root)
const rootDiv = ReactDOM.createRoot(root);
rootDiv.render(
<React.StrictMode>
<App />
</React.StrictMode>
);

Update App.css:

.App {
color: white;
text-align: center;
}
.container {
width: 15rem;
height: 15rem;
background-color: green;
}

Install Webpack:

Terminal window
npm install --save-dev webpack webpack-cli copy-webpack-plugin css-loader ts-loader html-webpack-plugin ts-node

Create a webpack.config.js file at the root of the project and add the following:

const path = require("path");
const HTMLPlugin = require("html-webpack-plugin");
const CopyPlugin = require("copy-webpack-plugin")
module.exports = {
entry: {
index: "./src/index.tsx"
},
mode: "production",
module: {
rules: [
{
test: /\.tsx?$/,
use: [
{
loader: "ts-loader",
options: {
compilerOptions: { noEmit: false },
}
}],
exclude: /node_modules/,
},
{
exclude: /node_modules/,
test: /\.css$/i,
use: [
"style-loader",
"css-loader"
]
},
],
},
plugins: [
new CopyPlugin({
patterns: [
{ from: "manifest.json", to: "../manifest.json" },
],
}),
...getHtmlPlugins(["index"]),
],
resolve: {
extensions: [".tsx", ".ts", ".js"],
},
output: {
path: path.join(__dirname, "dist/js"),
filename: "[name].js",
},
};
function getHtmlPlugins(chunks) {
return chunks.map(
(chunk) =>
new HTMLPlugin({
title: "React extension",
filename: `${chunk}.html`,
chunks: [chunk],
})
);
}

Update the package.json file to have the following scripts:

Terminal window
"scripts": {
"build": "webpack --config webpack.config.js",
"watch": "webpack -w --config webpack.config.js"
}

Chrome extensions require one file which is the manifest.json. At the root of your project create this and add the following content:

{
"version": "1.0.0",
"manifest_version": 3,
"name": "React Chrome Extension",
"description": "This is a Chrome extension built with React and TypeScript",
"action": {
"default_popup": "js/index.html",
"default_title": "React Chrome Extension"
}
}

Run npm run build to create your application in the /dist folder.

In Chrome, navigate to chrome://extensions and select “Load Unpacked”. Select the /dist directory.

You should now be able to open a page in chrome and click the extension icon in the upper right to display a big green popup. Okay, that’s a good start.

The process to create a Side Panel is fairly straightforward now that we have the basic infrastructure in place.

Since we will be using the global chrome namespace in our TypeScript project, we need to install the appropriate types:

Terminal window
npm i @types/chrome

In the /src directory of your project, create a background.ts file and add the following code:

chrome.runtime.onInstalled.addListener(() => {
chrome.sidePanel.setPanelBehavior({ openPanelOnActionClick: true });
});
export {}

First, add the sidePanel permission to the permissions list. Next, add a side_panel field with a nested default_path argument that points to your index.html file. The updated Manifest file should look like this.

{
"version": "1.0.0",
"manifest_version": 3,
"name": "React Chrome Extension",
"description": "This is a Chrome extension built with React and TypeScript",
"permissions": [
"sidePanel"
],
"side_panel": {
"default_path": "js/index.html"
},
"background": {
"service_worker": "js/background.js"
}
}

In your webpack.config.js file, add a new line to the entry object for the background script:

entry: {
index: "./src/index.tsx",
background: "./src/background.ts"
},

Run npm run build again to update your extension.

In chrome, navigate to the chrome://extensions page again and reload the extension. Now when you click on the icon, it should open the side panel showing the same large green box.

alt text

If you have opinions on how to write software, you probably feel a strong pull to organize the files of your project. Normally this is effortless but not for React Chrome extensions. If you move a file in this project, you will need to update the webpack.config.js and (sometimes) the manifest.json files to point to the correct location.

I like to put files that affect the entire app in an /app folder so I’ll move the background.ts file there.

Now that we’ve transplanted the background.ts file, we need to tell the Webpack config where to find it:

entry: {
index: "./src/index.tsx",
background: "./src/app/background.ts"
},

In this case the background.js file will still be located at the same location in the /dist folder so we odn’t have to update the manifest.json:

"background": {
"service_worker": "js/background.js"
}

Running npm run build and refreshing the extension one more time should give us an extension that works the same as before. Clicking on the icon should open the side panel ✅

Where things get a bit more complicated is when you want to create separate React components for each part of your extension; side panel, options page, popup, etc. Just as with the background.ts file, you can move these other components anywhere but there are a few requirements to make it all work.

Each component requires two files: an index.tsx and the component file (e.g. SidePanel.tsx).

For this example, I’ll be creating my side panel component in the /src/features/side_panel/ folder.

Create an index.tsx file:

import React from 'react';
import ReactDOM from 'react-dom/client';
import SidePanel from './SidePanel';
const root = document.createElement("div")
root.className = "container"
document.body.appendChild(root)
const rootDiv = ReactDOM.createRoot(root);
rootDiv.render(
<React.StrictMode>
<SidePanel />
</React.StrictMode>
);

Create a SidePanel.tsx file:

function SidePanel() {
return <div className="SidePanel">Side Panel</div>;
}
export default SidePanel;

We’ll need to update the entry field in the Webpack config to point to the new side panel index.tsx. The value you add as the key in the map will determine where in the /dist folder the files are generated:

Webpack entry keys determine where files are generated

The value (on the left side) is where Webpack can find your file.

In addition to this change, you will also need to update the HTML Plugin configuration to add the sidepanel:

const path = require("path");
const HTMLPlugin = require("html-webpack-plugin");
const CopyPlugin = require("copy-webpack-plugin");
module.exports = {
entry: {
index: "./src/index.tsx",
background: "./src/app/background.ts",
sidepanel: "./src/features/side_panel/index.tsx",
},
mode: "production",
module: {
rules: [
{
test: /\.tsx?$/,
use: [
{
loader: "ts-loader",
options: {
compilerOptions: { noEmit: false },
},
},
],
exclude: /node_modules/,
},
{
exclude: /node_modules/,
test: /\.css$/i,
use: ["style-loader", "css-loader"],
},
],
},
plugins: [
new CopyPlugin({
patterns: [{ from: "manifest.json", to: "manifest.json" }], // Adjusted path
}),
...getHtmlPlugins(["index", "sidepanel"]),
],
resolve: {
extensions: [".tsx", ".ts", ".js"],
},
output: {
path: path.join(__dirname, "dist"),
filename: "js/[name].js",
publicPath: "/",
},
};
function getHtmlPlugins(chunks) {
return chunks.map(
(chunk) =>
new HTMLPlugin({
title: "React extension",
filename: `${chunk}.html`,
chunks: [chunk],
})
);
}

Now that we’ve created a new sidepanel.html file in the /dist folder, we can use it in the manifest.json:

{
"version": "1.0.0",
"manifest_version": 3,
"name": "React Chrome Extension",
"description": "This is a Chrome extension built with React and TypeScript",
"permissions": [
"sidePanel"
],
"side_panel": {
"default_path": "sidepanel.html"
},
"background": {
"service_worker": "js/background.js"
}
}

Run npm run build one more time and reload the extension. Clicking on the extension icon should again open up the sidebar with the big green box, only this time you have a custom folder structure under the hood.

You can expand on this to add separate components for the Options page and Popup.

Since each component we’ve created runs in a separate context, we need to be creative with how they communicate with one another. One way to pass realtime data between them is using Chrome’s Messaging API.

In this example, I will be having my Options.tsx component send data to my SidePanel.tsx component. Here is the Options.tsx:

function Options() {
const sendMessage = () => {
chrome.runtime.sendMessage(
{ type: "UPDATE_FROM_OPTIONS", payload: "Hello SidePanel" },
(response) => {
console.log("Response from SidePanel:", response);
}
);
};
return (
<div className="App">
Options
<button onClick={sendMessage}>Send Message to SidePanel</button>
</div>
);
}
export default Options;

To receive the message, my SidePanel.tsx file will listen for all messages sent using chrome.runtime. Here is the SidePanel.tsx:

function SidePanel() {
useEffect(() => {
chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
if (message.type === "UPDATE_FROM_OPTIONS") {
// Handle the message
console.log("Received message in SidePanel:", message.payload);
sendResponse({ status: "Received by SidePanel" });
}
});
}, []);
return <div className="App">Side Panel</div>;
}
export default SidePanel;

After reloading the extension, first open the side panel by clicking on the extension icon. Then, right-click the icon and open the options page. From here, you should be able to press the button and send a message to the side panel (to see the console message, right click in the side panel and select “inspect”).

If you need to mediate messages between components, use the background script:

// In background.ts
chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
if (message.type === 'UPDATE_FROM_OPTIONS') {
// Forward the message to SidePanel
chrome.runtime.sendMessage(message, (response) => {
sendResponse(response);
});
return true; // Keep the messaging channel open for sendResponse
}
});

Messages sent using the Messages API are transient so if your extension requires more persistent data storage, you can use Chrome’s Storage API. Here’s a brief example:

// In Options.tsx
chrome.storage.local.set({ sharedData: 'Some data' }, () => {
console.log('Data is set in storage');
});
// In SidePanel.tsx
chrome.storage.local.get(['sharedData'], (result) => {
console.log('Data retrieved from storage:', result.sharedData);
});

To use TailwindCSS throughout your extension follow these steps.

Terminal window
npm install -D tailwindcss postcss autoprefixer

Run the following command to generate tailwind.config.js and postcss.config.js files at the root of your project:

Terminal window
npx tailwindcss init -p

Ensure the tailwind.config.js file can find your source files:

/** @type {import('tailwindcss').Config} */
module.exports = {
content: [
"./src/**/*.{js,jsx,ts,tsx,html}", // Adjust this path based on your project structure
],
theme: {
extend: {},
},
plugins: [],
};

In your App.css file, add the Tailwind styes:

@tailwind base;
@tailwind components;
@tailwind utilities;

Finally, add the postcss-loader to the css module in the webpack.config.js file:

module: {
rules: [
// TypeScript loader
{
test: /\.tsx?$/,
use: 'ts-loader',
exclude: /node_modules/,
},
// CSS and PostCSS loader
{
test: /\.css$/i,
use: ['style-loader', 'css-loader', 'postcss-loader'],
},
],
},

The code I shared above is already using the App.css file but if you add the Tailwind styles to a different file, you’ll need to add that as an import in each index.tsx file. For example:

import React from 'react';
import ReactDOM from 'react-dom/client';
import '../../tailwind.css'; // Import Tailwind CSS
import Options from './Options';
const root = document.createElement('div');
root.className = 'container';
document.body.appendChild(root);
const rootDiv = ReactDOM.createRoot(root);
rootDiv.render(
<React.StrictMode>
<Options />
</React.StrictMode>
);

With this setup, you should be able to build robust Chrome extensions using the React framework and TailwindCSS. Happy coding ☕️