Build a Chrome extension using React and Webpack

Build a Chrome extension using React and Webpack

This tutorial will guide you through building a Chrome extension that displays the user's weather details in the bottom-right corner of every website. Key benefits include convenient weather information, personalised experience, improved productivity, and seamless Chrome integration.

You will learn how to:

  • Render a custom React component on any website

  • Bundle React code using Webpack

  • Fetch data in Chrome extensions

  • Set up the Chrome extension configuration

  • Communicate between content scripts and the extension

After completing this tutorial, you will gain skills in Chrome extension development, including accessing user location, fetching weather data, and designing a custom user interface. The result will be a functional extension

Before you start

This guide assumes that you have basic React development experience. I recommend checking out React Docs and Chrome Extension Docs and getting an API key from weatherapi

Build the extension

To start, create a new weather directory to hold the extension's files.

Add the extension data and icons

Create a file called manifest.json and include the following code;

{
  "manifest_version": 3,
  "name": "Weather Widget",
  "description": "Extension that shows the day weather and forecast .",
  "version": "1.0",
  "icons": {
    "16": "images/weather-46-16.png",
    "32": "images/weather-46-32.png",
    "48": "images/weather-46-48.png",
    "128": "images/weather-46-128.png"
  },
  "content_scripts": [
    {
      "js": [
        "contentScript.js"
      ],
      "matches": [
        "<all_urls>"
      ]
    }
  ],
  "background": {
    "service_worker": "background.js"
  },
  "action": {
    "default_title": "Weather",
    "default_popup": "popup.html"
  },
  "author": "Tobiloba Adebisi https://www.github.com/Adebisi1234",
  "host_permissions": [
    "<all_urls>"
  ],
  "permissions": [
    "storage",
    "geolocation"
  ],
  "web_accessible_resources": [
    {
      "resources": [
        "images/refresh-34-16.png"
      ],
      "matches": [
        "<all_urls>"
      ]
    }
  ]
}

Create an images folder then download the icons

Add dependencies

Configure your package.json file like this;

{
  "name": "weather",
  "version": "1.0.0",
  "description": "",
  "main": "main.js",
  "type": "module",
  "scripts": {
    "build": "webpack --config webpack.config.cjs",
    "watch": "webpack -w --config webpack.config.cjs",
    "types": "npx tsc"
  },
  "keywords": [],
  "author": "Tobiloba Adebisi https://github.com/adebisi1234",
  "license": "ISC",
  "devDependencies": {
    "@babel/preset-env": "^7.25.0",
    "@babel/preset-react": "^7.24.7",
    "@babel/preset-typescript": "^7.24.7",
    "@types/chrome": "^0.0.269",
    "@types/react": "^18.3.3",
    "@types/react-dom": "^18.3.0",
    "babel-loader": "^9.1.3",
    "copy-webpack-plugin": "^12.0.2",
    "css-loader": "^7.1.2",
    "html-loader": "^5.1.0",
    "html-webpack-plugin": "^5.6.0",
    "style-loader": "^4.0.0",
    "typescript": "^5.5.4",
    "webpack": "^5.93.0",
    "webpack-cli": "^5.1.4"
  },
  "dependencies": {
    "react": "^18.3.1",
    "react-dom": "^18.3.1"
  }
}

And then install the dependencies using npm install

Setup Webpack

Create a webpack.config.cjs file and include the following code.

const path = require("path");
const HTMLPlugin = require("html-webpack-plugin");
const CopyPlugin = require("copy-webpack-plugin");

module.exports = {
  entry: {
    contentScript: "./src/content/index.tsx",
    background: "./src/background/index.tsx",
    installed: "./src/installed/index.tsx",
    popup: "./src/popup/index.tsx",
  },
  mode: "production",
  target: "web",
  module: {
    rules: [
      {
        test: /\.tsx$/,
        use: {
          loader: "babel-loader",
          options: {
            presets: [
              "@babel/preset-env",
              ["@babel/preset-react", { runtime: "automatic" }],
              "@babel/preset-typescript",
            ],
          },
        },
        exclude: /node_modules/,
      },
      {
        test: /\.html$/,
        loader: "html-loader",
        exclude: /node_modules/,
      },
      {
        exclude: /node_modules/,
        test: /\.css$/i,
        use: ["style-loader", "css-loader"],
      },
    ],
  },
  plugins: [
    new HTMLPlugin({
      template: path.join(__dirname, "src", "popup", "index.html"),
      filename: "popup.html",
      chunks: ["popup"],
      cache: false,
    }),
    new HTMLPlugin({
      template: path.join(__dirname, "src", "installed", "index.html"),
      filename: "installed.html",
      chunks: ["installed"],
      cache: false,
    }),
    new CopyPlugin({
      patterns: [
        { from: path.resolve("manifest.json"), to: path.resolve("dist") },
        { from: path.resolve("images"), to: path.resolve("dist/images") },
      ],
    }),
  ],
  resolve: {
    extensions: [".tsx", ".ts", ".js"],
  },
  output: {
    path: path.resolve(__dirname, "dist"),
    filename: "[name].js",
    clean: true,
  },
};

This defines how Webpack will bundle our React code, it'll emit the following files in the dist folder

  • /images - Folder containing all images used in the extension.

  • background.js - Service worker used to fetch weather data.

  • manifest.json - Configuration for the extension.

  • installed.html - The webpage is shown when the extension gets installed

  • popup.html - The webpage shows when the extension gets clicked

  • contentScript.js - React component that gets injected into every webpage

  • installed.js - React component for the installed.html page

  • popup.js - React component for the popup page

File Structure

Create the following files and folder in the src folder

Installed page

This page is used to collect the user's location details that will be used for fetching the weather data.

Include the following code in /src/installed/index.html

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>Welcome to weather extension</title>
  </head>
  <body>
    <div id="root"></div>
  </body>
</html>

A simple HTML page that defines the root element <div id="root"></div> in which your React component gets rendered.

To attach your React component for the installed page to the root element, include the following code in `/src/installed/index.tsx.

import React from "react";
import { createRoot } from "react-dom/client";
import Installed from "./Installed";


const root = createRoot(document.getElementById("root") as HTMLElement)
root.render(<Installed />);

In /src/installed/Installed.tsx include the following code

import React, { useEffect, useRef, useState } from 'react'

export default function Installed() {
    const inputRef = useRef<HTMLInputElement>(null)
    const [useGeoLocation, setUseGeoLocation] = useState(false)
    const [loading, setLoading] = useState(false)
    useEffect(() => {
        if (!useGeoLocation) return;

        navigator.geolocation.getCurrentPosition(async (position) => {
            await chrome.storage.local.set({ location: { type: "name", value: `${position.coords.latitude},${position.coords.longitude}` } })

            window.close()
        }, (error) => {
            alert("Error getting geolocation" + error.message)

        })

    }, [useGeoLocation])
    return (
        <div className='container'>
            <h4>Welcome to the weather widget</h4>
            <p>Please enter your location, to get accurate forecasts</p>
            <form onSubmit={(e) => {
                e.preventDefault()
                setLoading(true)
            }}>
                <fieldset>
                    <input type="text" placeholder='city name' ref={inputRef} />
                    <button type='submit' onClick={async () => {
                        if (!inputRef.current || inputRef.current.value.trim() === "") return;
                        await chrome.storage.local.set({ location: { type: "name", value: inputRef.current.value } })
                        window.close()
                    }}>Submit</button>
                </fieldset>
                <button type='submit' onClick={() => {
                    setUseGeoLocation(true)
                }} >Use Geolocation</button>
            </form>
            {loading && <p>Setting up...</p>}
        </div>
    )
}

This gets the user's location either by using the geolocation API or the user's manually inputted location and then saves it using the Storage API.

Service worker

Here, we define the background service, and these are its functions:

  • Open the Installed page when the extension gets installed

    in /src/background/index.tsx include the following code

      chrome.runtime.onInstalled.addListener(function (object) {
          const internalUrl = chrome.runtime.getURL("installed.html");
    
          if (object.reason === chrome.runtime.OnInstalledReason.INSTALL) {
              chrome.tabs.create({ url: internalUrl }, function (tab) {
                  console.log("New tab launched with installed.html");
              });
          }
      });
    
  • Fetch weather data - The content script can't make API calls, so the task will fall on the background service. In the index.tsx file, include the following code:

      export const BASE_URL = "https://api.weatherapi.com/v1";
      export const API_KEY = /*<API_KEY>*/;
      chrome.runtime.onMessage.addListener(async function (msg) {
          if (msg.type === "weather") {
              const activeId = await chrome.tabs.query({ active: true, currentWindow: true })
              const response = await getWeather()
              chrome.tabs.sendMessage(activeId[0]?.id ?? 0, response)
          }
      })
    
      async function getWeather() {
          const cache = await chrome.storage.local.get("weather")
          // Same Day
          if (cache?.weather?.date === new Date().toDateString() && cache.weather.data) {
              return { message: "success", data: cache.weather.data }
          } else {
              const loc = await chrome.storage.local.get("location")
              console.log(loc.location)
              const data = await fetch(`${BASE_URL}/current.json?key=${API_KEY}&q=${loc.location.value}`)
              const res = await data.json()
              console.log(res)
    
              if (data.ok) {
                  chrome.storage.local.set({ "weather": { data: res, date: new Date().toDateString() } })
                  return { message: "success", data: res }
    
              } else {
                  return { message: "failed", data: res }
              }
          }
      }
    

This caches the weather data gotten from the weather API, only gets new results once daily, and returns the data to the content script.

Content Script

First, you create a DOM element which your React component can get attached to on the website

In your /src/content/index.tsx file, include the following code:

import React from "react";
import { createRoot } from "react-dom/client";
import Widget from "./Widget";
import "./styles.css";

setTimeout(() => renderWidget());

function renderWidget() {
  const rootEl = document.createElement("div");
  rootEl.id = "widget_root";
  document.body.append(rootEl);
  console.log(document.getElementById("widget_root"));
  const root = createRoot(document.getElementById("widget_root")!);
  root.render(<Widget />);
}

The setTimeout ensures the renderWidget function gets called only after the page has been loaded. Next, you create the React component that shows the weather details

In the /src/content/Widget.tsx file, include the following code:

import React, { useEffect, useState } from "react";
import "./styles.css";
export interface Weather {
  location: {
    name: string;
    region: string;
    country: string;
    lat: number;
    lon: number;
    tz_id: string;
  };
  current: {
    last_updated: string;
    temp_c: string;
    temp_f: string;
    condition: {
      text: string;
      icon: string;
      code: number;
    };
  };
}

export default function Widget() {
  const [useCel, setUseCel] = useState(true);
  const [weather, setWeather] = useState<Weather | null>(null);

  const [loading, setLoading] = useState(true);
  useEffect(() => {
    (async () => {
      chrome.runtime.sendMessage({ type: "weather" });
      chrome.runtime.onMessage.addListener((response) => {
        console.log(response);
        if (response.message === "success") {
          setWeather(response.data);
          setLoading(false);
        }
      });
    })();
  }, []);
  return (
    <>
      {loading ? (
        <div>loading...</div>
      ) : (
        weather && (
          <div>
            <span className="badge">{weather?.location.name}</span>
            <div className="container">
              <div className="details">
                <img
                  src={`https://${weather?.current.condition.icon.substring(
                    2
                  )}`}
                  alt={weather?.current.condition.text}
                  width={16}
                  height={16}
                />
                <button onClick={() => setUseCel((prev) => !prev)}>
                  <p>
                    {useCel
                      ? `${weather?.current.temp_c}°C`
                      : `${weather?.current.temp_f}°F`}
                  </p>
                </button>
              </div>
              <div
                className="reload"
                onClick={() => chrome.runtime.sendMessage({ type: "weather" })}
              >
                <svg
                  viewBox="0 0 1024 1024"
                  version="1.1"
                  xmlns="http://www.w3.org/2000/svg"
                >
                  <path
                    d="M904.161882 587.294118c-13.733647 0-25.238588 9.456941-28.973176 22.708706C832.451765 761.976471 693.007059 873.411765 527.36 873.411765c-165.586824 0-305.091765-111.435294-347.828706-263.408941-3.704471-13.251765-15.239529-22.708706-28.973176-22.708706-19.968 0-34.304 19.184941-28.912942 38.4C171.188706 803.297882 333.914353 933.647059 527.299765 933.647059c193.475765 0 356.141176-130.349176 405.775059-307.952941a30.177882 30.177882 0 0 0-28.912942-38.4M150.528 436.705882c13.733647 0 25.268706-9.487059 28.973176-22.708706C222.238118 262.023529 361.743059 150.588235 527.36 150.588235c165.616941 0 305.121882 111.435294 347.858824 263.408941 3.704471 13.221647 15.209412 22.708706 28.943058 22.708706 19.998118 0 34.304-19.184941 28.912942-38.4C883.471059 220.702118 720.805647 90.352941 527.36 90.352941S171.218824 220.702118 121.615059 398.305882a30.177882 30.177882 0 0 0 28.912941 38.4"
                    fill="#fff"
                  />
                  <path
                    d="M86.949647 369.392941l138.691765 33.822118a12.047059 12.047059 0 0 1 5.180235 20.690823l-100.201412 89.840942a12.047059 12.047059 0 0 1-19.546353-5.421177L72.583529 384.692706a12.047059 12.047059 0 0 1 14.366118-15.269647zM972.077176 646.896941l-141.131294-21.62447a12.047059 12.047059 0 0 1-6.927058-20.148706l91.949176-98.213647a12.047059 12.047059 0 0 1 19.937882 3.644235l49.121883 119.868235a12.047059 12.047059 0 0 1-12.950589 16.474353z"
                    fill="#fff"
                  />
                </svg>
              </div>
            </div>
          </div>
        )
      )}
    </>
  );
}

Since content scripts can't make API calls, we send a message to the service worker for the weather details, then listen for its response, and show the data if the response was successful.

then, the styling for the widget

Include the following in /src/content/styles.css

#widget_root {
  width: fit-content;
  height: fit-content;
  position: fixed;
  left: 99%;
  top: 98%;
  transform: translateX(-100%) translateY(-100%);
  color: white;
  padding: 10px;
  border-radius: 8px;
  /* From https://css.glass */
  background: rgba(102, 51, 153, 0.08);
  border-radius: 16px;
  box-shadow: 0 4px 30px rgba(0, 0, 0, 0.1);
  backdrop-filter: blur(3.9px);
  -webkit-backdrop-filter: blur(3.9px);
  border: 1px solid rgba(102, 51, 153, 0.3);
}

.container {
  display: flex;
  justify-content: center;
  align-items: center;
  gap: 10px;
}
.details {
  display: flex;
  justify-content: center;
  align-items: center;
  gap: 4px;
}
.reload {
  width: 16px;
  height: 16px;
  display: flex;
  align-items: center;
}
.badge {
  border-radius: 4px;
  font-size: 12px;
  background-color: rgb(52, 52, 52);
  color: aliceblue;
  padding: 2.5px;
}

svg {
  width: 16px;
  height: 16px;
}

Popup page

For this page, you'll be reusing the Widget component (that's the fun of React)

  1. Create a DOM element your React code gets attached to

    In /src/popup/index.html include the following code

     <!DOCTYPE html>
     <html lang="en">
       <head>
         <meta charset="UTF-8" />
         <meta name="viewport" content="width=device-width, initial-scale=1.0" />
         <style>
           html {
             min-height: 5em;
             min-width: 10em;
             /* From https://css.glass */
             background: rgba(102, 51, 153, 0.3);
             border-radius: 16px;
             box-shadow: 0 4px 30px rgba(0, 0, 0, 0.1);
             backdrop-filter: blur(3.9px);
             -webkit-backdrop-filter: blur(3.9px);
             border: 1px solid rgba(102, 51, 153, 0.3);
             color: black;
           }
         </style>
         <title>Weather Widget</title>
       </head>
       <body>
         <div id="root"></div>
       </body>
     </html>
    
  2. Couple your react component to the HTML

    in /src/popup/index.tsx add the following code

     import React from "react";
     import { createRoot } from "react-dom/client";
     import App from "./App";
    
     const root = createRoot(document.getElementById("root") as HTMLElement)
     root.render(<App />);
    
  3. Since the popup page can make API calls, we'll use it to fetch the weather data and reuse the Widget component. Include the following code in your /src/popup/App.tsx file:

     import React, { useEffect, useState } from 'react'
     import { API_KEY, BASE_URL } from '../background'
     import Widget, { Weather } from '../content/Widget'
    
     export default function App() {
         const [data, setData] = useState<Weather | null>(null)
    
         useEffect(() => {
             (async () => setData((await getWeather()).data))()
         }, [])
         return (
             <>{data ? <Widget data={data} /> : <p>Loading...</p>}</>
         )
     }
    
     async function getWeather() {
         const cache = await chrome.storage.local.get("weather")
         // Same Day
         if (cache?.weather?.date === new Date().toDateString() && cache.weather.data) {
             return { message: "success", data: cache.weather.data }
         } else {
             const loc = await chrome.storage.local.get("location")
             console.log(loc.location)
             const data = await fetch(`${BASE_URL}/current.json?key=${API_KEY}&q=${loc.location.value}`)
             const res = await data.json()
             console.log(res)
    
             if (data.ok) {
                 chrome.storage.local.set({ "weather": { data: res, date: new Date().toDateString() } })
                 return { message: "success", data: res }
    
             } else {
                 return { message: "failed", data: res }
             }
         }
     }
    
  4. To make this work, we'll need to edit our widget component a bit. Add the following code to your /src/content/Widget.tsx file:

   ...
   export default function Widget({ data }: { data?: Weather }) {
     const [useCel, setUseCel] = useState(true);
     if (data) {
       return (
         <div>
           <span className="badge">{data?.location.name}</span>
           <div className="container">
             <div className="details" title={data?.current.condition.text}>
               <img
                 src={`https://${data?.current.condition.icon.substring(2)}`}
                 alt={data?.current.condition.text}
                 width={16}
                 height={16}
               />
               <button
                 onClick={() => setUseCel((prev) => !prev)}
                 title={data?.current.condition.text}
               >
                 <p>
                   {useCel
                     ? `${data?.current.temp_c}°C`
                     : `${data?.current.temp_f}°F`}
                 </p>
               </button>
             </div>
           </div>
         </div>
       );
     }
     const [weather, setWeather] = useState<Weather | null>(null);
   ...

Finally, compile your code using the npm run build command and you'll have your bundled Chrome extension under the dist directory

Load your extension locally

To load an unpacked extension in developer mode, follow the steps in the docs.

After all this, you should have an extension that looks like the one in the image below.

With the skills you've acquired, you can potentially extend this project to include additional features, such as weather alerts, customisable widget settings, or integration with other web services. The possibilities are endless for building Chrome extensions that improve your daily workflow and provide valuable information at your fingertips. For further reading on Chrome extension development and related topics, you might consider Chrome Extensions Documentation

Thanks for reading. If you prefer, you can download the complete source code from GitHub.

Please follow me here and below: