Hi, It may seem that the internet is full of guides, however, I haven't found any comprehensive, one that could cover all the steps of building extension from a project setup to deployment. So in this post, I will try to fix this this enormous problem and cover all these steps. Feel free to navigate to the needed one if you already pass through some, using the navigation links below:
Environment setup
First of all, let's setup the project using Vite with React and install some dependencies.
npm create vite@latest chrome-extension -- --template react-swc-ts
npm i
npm run dev
As a result, the app is running on some port, in my case it is 5173. That is not the common 3000 port, it may seem as nothing but for me I caused lots of troubles on the deployment stage, so lets change this to common 3000 port. It can be done inside vite.config.ts
file by defining server properly and assigning port to 3000:
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react-swc";
// https://vitejs.dev/config/
export default defineConfig({
plugins: [react()],
server: {
port: 3000,
},
});
Now restart the app, it will be running on the 3000 port.
For styling, I prefer using tailwindcss so let's install this:
npm install -D tailwindcss postcss autoprefixer
npx tailwindcss init -p
And add the Tailwind directives to index.css
:
@tailwind base;
@tailwind components;
@tailwind utilities;
Alos for merging classes I will install two more libs:
npm install -D tailwind-merge clsx
And create utils.ts
with cn
function that will help merging classes:
import { type ClassValue, clsx } from "clsx";;
import { twMerge } from "tailwind-merge";
export const cn = (...inputs: ClassValue[]) => twMerge(clsx(inputs));
Manifest
The base setup is over. Now for working inside the browser as an extension app needs the manifest file, which is the important part as the manifest defines the scripts, pages, and permission that the app requires for working.
For these, we need to install CRXJS lib and create manifest.json
file in the root of directory:
npm i @crxjs/vite-plugin@beta -D
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react-swc";
import { crx } from "@crxjs/vite-plugin";
import manifest from "./manifest.json";
// https://vitejs.dev/config/
export default defineConfig({
plugins: [react(), crx({ manifest })],
server: {
port: 3000,
},
});
{
"manifest_version": 3,
"name": "Chrome extension",
"version": "1.0.0",
"action": { "default_popup": "index.html" },
"icons": {
"16": "icon_logo_16px.png",
"32": "icon_logo_32px.png",
"48": "icon_logo_48px.png",
"128": "icon_logo_128px.png"
}
}
The icons are not required they just improve experience and should stored in public folder.
Restart the app and open the chrome -> chrome://extensions/. Enable the developer mode, click load unpacked and select the dist folder inside your repository. In a result your extension will be available for testing and development and if you active it you will get something like this:
Implementing feature
Now that the setup is over we need to build the app, however, make sure that if you build some custom extension your manifest fulfils all needs requirement (for cases if the extension directly interacts with a current tab, collect some data, etc.). I will build just a simple calculator so my manifest does not require any addition. I will not explain the logic behind the app and jus share the code for it:
import { Calculator } from "./components/calculator";
function App() {
return (
<div className="container w-[25rem] p-1 bg-zinc-800">
<Calculator />
</div>
);
}
export default App;
import { cn } from "../utils";
interface ButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {}
export const Button: React.FC<ButtonProps> = ({ className, ...props }) => {
return (
<button
className={cn(
"text-white px-2 py-4 text-2xl rounded-md bg-zinc-300/10 hover:bg-zinc-300/40 transition-colors duration-300",
className
)}
{...props}
/>
);
};
interface DisplayProps {
value: string;
}
export const Display: React.FC<DisplayProps> = ({ value }) => {
return (
<div className="text-right text-4xl p-2 pr-3 text-white w-full bg-transparant rounded-md">
{value}
</div>
);
};
import { useReducer } from "react";
import { Display } from "./display";
import { Button } from "./buttons";
interface CalculatorState {
value: number | null;
displayValue: string;
operator: string | null;
waitingForOperand: boolean;
}
type CalculatorAction = {
type:
| "inputDigit"
| "inputDot"
| "toggleSign"
| "inputPercent"
| "performOperation"
| "clearAll"
| "clearDisplay"
| "clearLastChar";
payload?: any;
};
const CalculatorOperations: {
[key: string]: (prevValue: number, nextValue: number) => number;
} = {
"/": (prevValue, nextValue) => prevValue / nextValue,
"*": (prevValue, nextValue) => prevValue * nextValue,
"+": (prevValue, nextValue) => prevValue + nextValue,
"-": (prevValue, nextValue) => prevValue - nextValue,
"=": (nextValue) => nextValue,
};
const initState: CalculatorState = {
value: null,
displayValue: "0",
operator: null,
waitingForOperand: false,
};
const reducer = (state: CalculatorState, action: CalculatorAction) => {
switch (action.type) {
case "inputDigit": {
console.log(action);
if (state.waitingForOperand) {
return {
...state,
displayValue: String(action.payload),
waitingForOperand: false,
};
}
console.log(state);
return {
...state,
displayValue:
state.displayValue === "0"
? String(action.payload)
: state.displayValue + action.payload,
};
}
case "inputDot": {
const { displayValue } = state;
if (!/\./.test(displayValue)) {
return {
...state,
displayValue: displayValue + ".",
waitingForOperand: false,
};
}
return state;
}
case "toggleSign": {
const { displayValue } = state;
const newValue = parseFloat(displayValue) * -1;
return { ...state, displayValue: String(newValue) };
}
case "inputPercent": {
const { displayValue } = state;
const currentValue = parseFloat(displayValue);
if (currentValue === 0) return state;
const fixedDigits = displayValue.replace(/^-?\d*\.?/, "");
const newValue = parseFloat(displayValue) / 100;
return {
...state,
displayValue: String(newValue.toFixed(fixedDigits.length + 2)),
};
}
case "performOperation": {
const { value, displayValue, operator } = state;
const inputValue = parseFloat(displayValue);
const newState = { ...state };
if (value == null) {
newState.value = inputValue;
}
if (operator) {
const currentValue = value || 0;
const newValue = CalculatorOperations[operator](
currentValue,
inputValue
);
newState.value = newValue;
newState.displayValue = String(newValue);
}
newState.waitingForOperand = true;
newState.operator = action.payload;
return newState;
}
case "clearAll": {
return {
value: null,
displayValue: "0",
operator: null,
waitingForOperand: false,
};
}
case "clearDisplay": {
return {
...state,
displayValue: "0",
};
}
case "clearLastChar": {
const { displayValue } = state;
return {
...state,
displayValue: displayValue.substring(0, displayValue.length - 1) || "0",
};
}
default:
return state;
}
};
export const Calculator: React.FC = () => {
const [state, dispatch] = useReducer(reducer, initState);
const { displayValue } = state;
const clearDisplay = displayValue !== "0";
const clearText = clearDisplay ? "C" : "AC";
return (
<>
<div className="mb-1">
<Display value={displayValue} />
</div>
<div className="grid grid-cols-4 gap-[0.2rem]">
<Button
onClick={() =>
clearDisplay
? dispatch({ type: "clearDisplay" })
: dispatch({ type: "clearAll" })
}
>
{clearText}
</Button>
<Button onClick={() => dispatch({ type: "toggleSign" })}>±</Button>
<Button onClick={() => dispatch({ type: "inputPercent" })}>%</Button>
<Button
onClick={() => dispatch({ type: "performOperation", payload: "/" })}
>
÷
</Button>
<Button onClick={() => dispatch({ type: "inputDigit", payload: 7 })}>
7
</Button>
<Button onClick={() => dispatch({ type: "inputDigit", payload: 8 })}>
8
</Button>
<Button onClick={() => dispatch({ type: "inputDigit", payload: 9 })}>
9
</Button>
<Button
onClick={() => dispatch({ type: "performOperation", payload: "*" })}
>
×
</Button>
<Button onClick={() => dispatch({ type: "inputDigit", payload: 4 })}>
4
</Button>
<Button onClick={() => dispatch({ type: "inputDigit", payload: 5 })}>
5
</Button>
<Button onClick={() => dispatch({ type: "inputDigit", payload: 6 })}>
6
</Button>
<Button
onClick={() => dispatch({ type: "performOperation", payload: "-" })}
>
−
</Button>
<Button onClick={() => dispatch({ type: "inputDigit", payload: 1 })}>
1
</Button>
<Button onClick={() => dispatch({ type: "inputDigit", payload: 2 })}>
2
</Button>
<Button onClick={() => dispatch({ type: "inputDigit", payload: 3 })}>
3
</Button>
<Button
onClick={() => dispatch({ type: "performOperation", payload: "+" })}
>
+
</Button>
<Button
onClick={() => dispatch({ type: "inputDigit", payload: 0 })}
className="col-span-2"
>
0
</Button>
<Button
onClick={() =>
dispatch({
type: "inputDot",
})
}
>
●
</Button>
<Button
onClick={() => dispatch({ type: "performOperation", payload: "=" })}
>
=
</Button>
</div>
</>
);
};
Deployment
To be able to deploy extension you need to register in Chrome web store as a developer, which requires payment, however, then you will be able to deploy as many extensions as you want. Now need to build an app and compress the dist folder to ZIP:
npm run build
Click new item in the dashboard and upload the compressed build, after that, you need to fill in all necessary info regarding your extension:
The Save draft button will help you find missing fields and other errors. When all are filed and complete you will be able to submit an extension for a review, it may take from a few minutes to a few days depending on what you app supposed to do (for me it took several hours).
After the extension is reviewed, it will be published automatically, you can find it in store using its id:
Now the extension is publicly available for everyone, so enjoy it.
Conclusion
I do not know what needs to be sad in the end... I hope I covered all the steps to build and deploy a simple extension using React and Vite. Below this, I post links to the repo and extension in the store maybe they will be useful for someone))
Top comments (3)
After setup the project, don't forget to fill in content in tailwind.config.js, it's not written here.
I have a question... how to use environment variables in web extension built with React + Vite?
why using crxjs for this? what is purpose? not explained