Forem

Cover image for Creating a Browser Extension in 2025
Sby
Sby

Posted on

Creating a Browser Extension in 2025

The Modern Way

In this post we'll look at the modern way of making a browser extension.

The web ecosystem has evolved over the years and with it, the browser extensions.
As the regular web apps, the bundlers have gained massive popularity among developers.

Vite is a build tool that has risen in usage in the past years.
It accepts plugins in the config to modify its behaviour and there exists one for creating a browser extensions, Vite Plugin Web Extension.

This plugin helps a lot with development, including hot reloading the extension in the browser and building for multiple browsers from the same source.

Alongside we're using webextension-polyfill to simplify access to browser's APIs.

Project Structure

The file placement for a browser extension differs from a regular webapp.
It is mainly managed using required manifest.json file.

Rather than the conventional index.html, the entrypoint HTML file is typically called the popup (popup.html).
It is declared in the manifest under action (Chrome) or browser_action (Firefox) field.

Like a regular webpage it can load CSS, JS and even "web-unsafe" fonts bundled with your extension.

However, to actually interact with the webpages you visit in the browser, you need a content script, typically called content.js.
You have to declare them in the manifest under content_scripts field, which takes an array of objects with a required field matches, which specifies on what websites will the content script be loaded.

Note
For this you need to declare scripting permission in the permissions array of manifest.json

There is also background.js which is used as a web worker.

What this means in practice, however, is that extensions don't follow the typical webapp structure.
Unfortunately, full-stack frameworks like SvelteKit, Nuxt and SolidStart are not at all supported even though they're based on Vite.
That unforuntately also means no SSG, despite the extension being completely static.

How to Actually Do Something

The popup script and the content script need to communicate to actually perform work.
This is done using runtime messages browser API.

The content script is loaded on webpage load and starts listening for messages.

function listen(
  message: unknown,
  _sender: browser.Runtime.MessageSender,
  sendResponse: any,
): void {}

browser.runtime.onMessage.addListener(listen);
Enter fullscreen mode Exit fullscreen mode

The popup script is loaded with the popup HTML and it sends the message to the content script.
But first it must identify which tab the script is loaded in and in this case we are querying the tabs API.

Note
For this query you need to declare activeTab permission in the permissions array of manifest.json

const tab: browser.Tabs.Tab = (
  await browser.tabs.query({ active: true, currentWindow: true })
)[0];
Enter fullscreen mode Exit fullscreen mode

Then you pass the id of the active tab alongside your message.

interface Message {}
interface ExtensionResponse {
  data: string;
}

const resp: ExtensionResponse = await browser.tabs.sendMessage(id, {
  action: "getImages",
} as Message);
Enter fullscreen mode Exit fullscreen mode

The content script responds by using the sendMessage callback passed to the listener

sendResponse({
  data, 
} satisfies ExtensionResponse);
Enter fullscreen mode Exit fullscreen mode

And that's it for this exploration of the modern webextension landscape.
Feel free to share thoughts and questions in the comments

Random Useful Trivia

These are the basics to get you started with the development.
Here I would like to share some things you might find useful.

webextension-polyfill uses a synthetic import in

import browser from "webextension-polyfill";
Enter fullscreen mode Exit fullscreen mode

so you have to allow it in tsconfig.json.
It's allowed by default but your linter may want to disallow it for tree-shaking.


If your content.js throws an error, it gets silenced by the webpage and doesn't show up in the DevTools console.
During development it's recommended to wrap your listener in a try {} catch (error) {} block.


When you install the extension locally after building it using unpacked load, the browser reads directly from the directory, it doesn't copy the files.
It does mean faster development cycle, however that also means the dev changes affect your release installation as vite-plugin-web-extension dev build directly overwrites the dist/ folder.
You may probably want to create a separate folder, with a script like this.

import { execSync } from "node:child_process";
import { cp, rm } from "node:fs/promises";

await rm("build", { recursive: true, force: true });
execSync("vite build");
await cp("dist", "build", { recursive: true });
Enter fullscreen mode Exit fullscreen mode

Resources

Check out the docs
https://vite-plugin-web-extension.aklinker1.io/
https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions
https://developer.chrome.com/docs/extensions

Top comments (0)