Simplify Chrome Extension Development: Add React without CRA
Kristian Ivanov
Posted on September 23, 2024
Intro
We all love React, right (except for the Primeagen). It simplifies building complex UIs tremendously by providing an easy approach to handling states and access to a giant library of modules and components you can use from npm. It even comes up with CRA to bootstrap and make even the initial setup just one command.
I am a fan of Create-React-App (CRA). It is great for most web apps and after having seen a lot of ejected apps from it, or ones that have been building/bundling it with gulp (I feel old sometimes) even without using TypeScript, I’ve grown to appreciate its simplicity and how easy it has made my life when starting any new project.
However, it doesn’t really cut it when building something like a Chrome extension and generates far too much bloat. Additionally, in something like an extension, the UI can be just a fraction of the project, with most of the logic being wrapped in the background/service worker or in the content scripts. This means adding a ton of bloat and tying up everything with the way React is easiest to use is not always the best approach.
Additionally, what if you started with something really simple on the UI side (just a few lines of HTML and tailwind) to try and test your idea, you wrote a bunch of code for the other parts of the extension and then decided to make the UI easier to maintain and scale and add React to it?
In this guide, we’ll build a Chrome extension using TypeScript, Webpack and Tailwind, and only then will bring React without CRA in much more lightweight way.
Setup
I recently wanted to start a very simple Chrome extension to explore some potential use cases Mutation Observer to customize pages. I also wanted to use this opportunity to play around with TypeScript since I haven’t had the chance to use it in a while.
1 — Set Up the Project
Let’s kick things off by creating a new directory for our project and initializing it with npm:
mkdir ts-chrome-extension
cd ts-chrome-extension
npm init -y
2 — Let’s add TypeScript
npm install --save-dev typescript
Once installed, we need to create a TypeScript configuration file. This file will tell the TypeScript compiler how to behave. Let’s create the tsconfig.json file:
{
"compilerOptions": {
"target": "es5",
"lib": ["dom", "es2015"],
"module": "esnext",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true
},
"include": ["src/**/*"]
}
More details on the tsconfig.json file can be found in the official docs
3 — Let’s add the basic files
mkdir -p src/scripts && mkdir src/popup && touch src/manifest.json src/scripts/{background.ts,contentScript.ts} src/popup/{popup.html,popup.ts}
Notice the multiple creates with touch in the same command. This is a neat way to save time that I’ll cover in a different article soon.
At this point, the structure of our folder should look like this:
├── package.json
├── package-lock.json
├── popup.ts
├── src
│ ├── manifest.json
│ ├── popup
│ │ ├── popup.html
│ │ └── popup.ts
│ └── scripts
│ ├── background.ts
│ └── contentScript.ts
└── tsconfig.json
4 directories, 9 files
You can list like this with the Linux tree command.
In this case with added *-I node_modules *to avoid the usual npm bloat.
What Each File Does:
background.ts: This script runs in the background to handle extension events like installation or message passing.
contentScript.ts: This is injected into the web pages to allow the extension to interact with the page’s content.
popup.html: The HTML file that defines the UI layout for your popup window.
popup.ts: Initially, this file handles direct DOM manipulation or event listeners. We’ll later replace this with a React component.
4 — The Manifest File
The manifest file is the backbone of your Chrome extension. It tells Chrome what your extension does and where to find its scripts. We’ve created ours, so let’s fill it out
{
"manifest_version": 3,
"name": "TypeScript Chrome Extension",
"version": "1.0",
"description": "A Chrome extension built using TypeScript.",
"action": {
"default_popup": "popup.html"
},
"background": {
"service_worker": "background.js"
},
"content_scripts": [
{
"matches": ["<all_urls>"],
"js": ["contentScript.js"]
}
],
"permissions": ["storage"]
}
Here’s a breakdown of what’s happening:
Action and Popup: The "action" key tells Chrome where the popup HTML is located. The popup will load when users click the extension icon.
Service Worker: The "service_worker" in the "background" key is needed for background tasks.
Content Scripts: These allow the extension to interact with web pages.
Permissions: The "storage" permission enables the extension to store user data via Chrome’s storage API.
You can find the Chrome extension’s manifest.json full documentation here.
5 — The Popup Files
Since we’re not using React yet, the popup will be simple and handle basic DOM interactions.
5.1 —popup.html
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Popup</title>
</head>
<body>
<div>
<h1>Hello, Chrome Extension!</h1>
<button id="alertButton">Click me</button>
</div>
<script src="popup.js"></script>
</body>
</html>
This is a simple HTML page with a button and a script reference to popup.js (which will be bundled from popup.ts).
5.2 — popup.ts
This file will handle basic interaction for the popup:
document.addEventListener('DOMContentLoaded', () => {
const alertButton = document.getElementById('alertButton');
if (alertButton) {
alertButton.addEventListener('click', () => {
alert('Button clicked!');
});
}
});
This simple script waits for the DOM to load, then adds a click listener to the button that triggers an alert. This structure sets the foundation for the popup functionality before we add React.
6 — Compile TypeScript
Let’s compile the TypeScript files into JavaScript. Run the following command:
npx tsc
This will transpile your TypeScript files into JavaScript. The popup.ts, background.ts, and contentScript.ts will be compiled to popup.js, background.js, and contentScript.js, respectively. But since we’ll soon add Webpack to manage the bundling, this is just a temporary step.
7 — Adding webpack for Bundling
Now that we have the basic structure, we need Webpack to handle bundling all our scripts into optimized files that Chrome can load. It can handle the copying of icons, transpiling ts into js, automatically injecting script or style files and much more.
7.1 — Install Webpack and necessary loaders
npm install --save-dev webpack webpack-cli ts-loader html-webpack-plugin mini-css-extract-plugin css-loader postcss postcss-loader copy-webpack-plugin
webpack: The core bundler.
ts-loader: Transpiles TypeScript for Webpack.
html-webpack-plugin: Helps generate HTML files with injected script tags.
CSS and PostCSS Loaders: For handling CSS (we’ll use these later with Tailwind).
CopyWebpackPlugin: The CopyWebpackPlugin ensures that the manifest.json and any other assets (like icons) are copied from the src folder to the dist folder.
— We define patterns, such as copying manifest.json from src/ to the root of the dist/ directory.
— If you have icons or other assets (e.g., images, fonts), you can add similar patterns.
7.2 — Webpack Configuration
Next, we need to configure Webpack to bundle everything. Create webpack.config.js at the root of your project:
const path = require('path'); // <-- This is missing and causes the ReferenceError
const HtmlWebpackPlugin = require('html-webpack-plugin');
const MiniCssExtractPlugin = require('mini-css-extract-plugin');
const CopyWebpackPlugin = require('copy-webpack-plugin');
module.exports = (env, argv) => {
const isProduction = argv.mode === 'production';
return {
entry: {
popup: './src/popup/popup.ts',
background: './src/scripts/background.ts',
contentScript: './src/scripts/contentScript.ts',
},
output: {
path: path.resolve(__dirname, 'dist'),
filename: '[name].js',
},
resolve: {
extensions: ['.ts', '.js'],
},
module: {
rules: [
{
test: /\.ts$/,
use: 'ts-loader',
exclude: /node_modules/,
},
{
test: /\.css$/i,
use: [MiniCssExtractPlugin.loader, 'css-loader', 'postcss-loader'],
},
],
},
plugins: [
new MiniCssExtractPlugin({
filename: '[name].css',
}),
new HtmlWebpackPlugin({
filename: 'popup.html',
template: 'src/popup/popup.html',
chunks: ['popup'],
}),
new CopyWebpackPlugin({
patterns: [
{ from: 'src/manifest.json', to: 'manifest.json' },
// { from: 'src/icons', to: 'icons' }, // Copy any additional assets
],
}),
],
mode: isProduction ? 'production' : 'development',
devtool: isProduction ? false : 'inline-source-map', // Disable source maps in production
};
};
Explanation:
Entry Points: We’ve specified the entry points for popup.ts, background.ts, and contentScript.ts.
Output: The bundled files will be output to the dist folder.
Loaders: We use ts-loader to handle TypeScript and css-loader/postcss-loader For CSS processing (later on when we introduce Tailwind CSS).
HtmlWebpackPlugin: This plugin automatically injects the bundled popup.js file into the popup.html file, ensuring that our scripts are correctly linked.
7.3 — Build the Extension with Webpack
Let’s bundle everything using Webpack by running:
npx webpack --mode development
This will output the bundled files into the dist directory.
If you are having issues because of the icons section, you can either remove it or put some placeholder .pngs for now.
You will have something like this
If you click the button you will get an alert. Twice. But why?
Remember how I said that webpack can inject script tags among other things? Check out your built popup.js in the dist folder and you will see the popup.js has been injected twice (once from the HTML file itself, and once from webpack).
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Popup</title>
<script defer src="popup.js"></script></head>
<body>
<div>
<h1>Hello, Chrome Extension!</h1>
<button id="alertButton">Click me</button>
</div>
<script src="popup.js"></script>
</body>
</html>
To fix this, simply remove the script tag in the popup.html in the src/popup folder so it looks like this:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Popup</title>
</head>
<body>
<div>
<h1>Hello, Chrome Extension!</h1>
<button id="alertButton">Click me</button>
</div>
</body>
</html>
Don’t worry. Webpack will still add it inside.
Test it out.
Take a look at the dist folder as well.
8 — Adding Tailwind for Styling.
Tailwind CSS is a utility-first CSS framework that allows you to style your UI without writing custom CSS for each component.
Over the years people have fallen in love with it. Sure, it is easier to add some basic styling on your own, but when you know you will be spending time on that project, Tailwind makes it easier to scale later on and keep it consistent for other people if they join you (the same class will always mean the same thing, vs when you implement it yourself, font-size-small can mean any number of things.)
8.1 — Installing Tailwind
npm install tailwindcss postcss postcss-loader autoprefixer
8.2 — Configure Tailwind and PostCSS
Next, we need to initialize Tailwind and configure PostCSS. Start by initializing Tailwind with the following command:
npx tailwindcss init
This will create a tailwind.config.js File in the root of your project. Open this file and modify it to include the paths where Tailwind should apply its styles (in our case, inside the src folder):
module.exports = {
content: ['./src/**/*.{ts,tsx,html}'],
theme: {
extend: {},
},
plugins: [],
};
The content key tells Tailwind where to look for classes in your files.
Next, create a postcss.config.js File in the root directory and configure it to use Tailwind and Autoprefixer:
module.exports = {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
};
8.3 — Create the Tailwind CSS Entry File
In src/styles, create a new file called tailwind.css. This is where we’ll import the basic Tailwind styles:
@tailwind base;
@tailwind components;
@tailwind utilities;
This file will include all of Tailwind’s utility classes.
8.4 — Modify the Webpack Configuration to Handle CSS
We need to ensure Webpack knows how to process CSS files using PostCSS. We already installed postcss-loader and css-loader earlier, so now we just need to make sure it’s properly configured.
Change this:
{
test: /\.css$/i,
use: [MiniCssExtractPlugin.loader, 'css-loader', 'postcss-loader'],
},
To this:
{
test: /\.css$/i,
use: [MiniCssExtractPlugin.loader, 'css-loader', 'postcss-loader'], // Process Tailwind CSS
},
This ensures Webpack can process Tailwind CSS when we include it in our popup. Speaking of this…
8.5 — Import Tailwin into the Popup
To start using Tailwind, we need to import the tailwind.css file into our popup script (popup.ts). Open popup.ts and add this import statement at the top:
import '../styles/tailwind.css';
8.6 — Add Some Simple Tailwind Styling
Let’s update the popup.html file to use some Tailwind classes for some basic styling. After all, all of the configs we just wrote and connected will be almost useless without it.
Can you guess or explain why it is ‘almost’ and not completely useless if we add tailwind but not any tailwind classes? Discuss it in the comments ;)
This is what our popup.html should look like now:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Popup</title>
</head>
<body class="bg-gray-100 p-4 w-80">
<div class="flex flex-col items-center">
<h1 class="text-xl font-bold mb-4">Hello, Chrome Extension!</h1>
<button id="alertButton" class="bg-blue-500 hover:bg-blue-700 text-white font-bold py-2 px-4 rounded">
Click me
</button>
</div>
<script src="popup.js"></script>
</body>
</html>
Here, we’ve added some basic Tailwind classes to style the popup:
Background: The popup’s background color is a soft gray (bg-gray-100).
Centered Content: Flexbox is used to center the content vertically and horizontally (flex flex-col items-center).
Styled Button: The button has Tailwind’s built-in button styles for hover effects, colors, and rounded corners (bg-blue-500, hover:bg-blue-700, text-white, rounded).
Let’s rebuild it and check the difference. -> npx webpack --mode development
Isn’t that better?
9 — Adding React to the Popup
We’ve got a functional extension with Tailwind styling and TypeScript for some syntax niceties and early problem detection. We also even have postcss and autoprefixer, which I’ll cover in a different article. And everything works.
Imagine however you need to make the UI more complex to manage loading and unloading data, handling tabs, and showing different UI based on the user’s subscription status. We might be getting ahead of ourselves, but for the sake of this guide, let’s assume there is a reason for it and add React to solve all of these problems. In my use case I added React to handle loading a dynamic set of data (which could’ve been done as a simpler template, but in combination with color pickers and switches of the UI based
9.1 — Install React and ReactDOM
npm install react react-dom @types/react @types-react-dom
9.2 Update Webpack for React
We need to update Webpack so it can handle .tsx files (React with TypeScript). Remember, this was the main goal we set up to do.
To handle this, modify the webpack.config.js file to include React as an entry point:
entry: {
popup: './src/popup/popup.tsx', // Update this to point to the new React component
...
},
resolve: {
extensions: ['.ts', '.tsx', '.js'], // Add .tsx to resolve for React files
},
module: {
rules: [
{
test: /\.tsx?$/, // Handle both .ts and .tsx files
...
9.3 — Convert popup.ts to React
Now we’re going to convert popup.ts to a React component. Rename popup.ts to popup.tsx and update it with a basic React component:
import React from 'react';
import { createRoot } from 'react-dom/client';
import '../styles/tailwind.css';
const Popup = () => {
const handleClick = () => {
alert('Button clicked!');
};
return (
<div className="flex flex-col items-center bg-gray-100 p-4">
<h1 className="text-xl font-bold mb-4">Hello, React Chrome Extension!</h1>
<button
className="bg-blue-500 hover:bg-blue-700 text-white font-bold py-2 px-4 rounded"
onClick={handleClick}
>
Click me
</button>
</div>
);
};
const container = document.getElementById('root')
const root = createRoot(container as HTMLDivElement)
root.render(<Popup />);
This simple React component mirrors the functionality of the original popup.ts but uses React’s onClick event for the button.
9.4 — Update popup.html
We don’t need to change much in popup.html except ensure it has the proper div to mount our React component. Here’s the updated popup.html:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Popup</title>
</head>
<body class="bg-gray-100 w-80">
<div id="root"></div>
</body>
</html>
9.5 — Update tsconfig.json
We need to add "jsx": "react" and "moduleResolution”: “node"to our tsconfig.json It should look like this:
"compilerOptions": {
"target": "es5",
"lib": ["dom", "es2015"],
"module": "esnext",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"jsx": "react",
"moduleResolution": "node"
},
"include": ["src/**/*"]
}
9.6 — Rebuild with Webpack
Once the changes are made, run Webpack to rebuild the extension -> npx webpack --mode development
Now, when you reload the extension in Chrome and open the popup, you should see a React-powered UI styled with Tailwind CSS.
Conclusion — Why Not Use Create-React-App?
CRA makes a lot of assumptions about how you’re building your app, bundling everything into a single page with dynamically injected JS. This approach works great for a React web app but doesn’t mesh well with Chrome extensions, which use separate HTML pages for the popup, background scripts, and sometimes even options pages.
The best parts of building without CRA? Control and learning.
You decide what gets included, how the bundling works, and how lean or rich your extension becomes.
I also believe it is a good idea to try and see how difficult something is without the library, framework, or bootstrapping helpers. It helps learn a bit more about all of the technologies that are being used indirectly and hidden from you.
If you have gotten this far, I thank you and I hope it was useful to you! Here is a cool image of a cat as a thank you!
Posted on September 23, 2024
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.