Justin Yoo’s Lift and Shift series on DevKimchi has already shown how a Chrome extension can be rebuilt with Blazor WebAssembly, and how JavaScript interop wires up the popup and options pages. This third and final part in the series closes the gap that most Blazor extension demos leave open: getting the same codebase to run on Firefox and other Mozilla based browsers, not just Chrome.
The Chrome extension APIs and the Firefox WebExtension APIs are close cousins, not identical twins. Firefox exposes a browser namespace that returns promises, while Chrome exposes a chrome namespace that mostly still relies on callbacks. If you have built an extension against the chrome APIs directly, moving it to Firefox is not a copy paste job. This article walks through the actual changes required, manifest.json, the background script, the popup and options scripts, and a small Blazor abstraction that keeps the JS interop code out of every page.
Bringing in the browser extension polyfill
Mozilla maintains a polyfill library that wraps the callback based chrome APIs in a promise based browser namespace. Once this library is loaded, you can write browser.storage.sync.get(…).then(…) instead of juggling callbacks, and the same code works on both browser families. The first instinct is to pull it straight from a CDN inside index.html.
<!DOCTYPE html>
<html lang="en">
...
<body>
<div id="app">Loading...</div>
...
<!-- Add this line -->
<script src="https://unpkg.com/browse/webextension-polyfill/dist/browser-polyfill.min.js"></script>
<!-- Add this line -->
<script src="_framework/blazor.webassembly.js"></script>
</body>
</html>
This looks reasonable, but browser extensions run under a strict Content Security Policy, and that policy blocks scripts loaded from a remote origin by default. Loading the polyfill straight from unpkg triggers a CSP violation error in the console, and the extension fails to pick up the browser namespace at all.

The fix is straightforward once you know the cause. Download the polyfill file from the CDN, save it under wwwroot/js/dist, and reference it as a local script instead of a remote URL.
<!DOCTYPE html>
<html lang="en">
...
<body>
<div id="app">Loading...</div>
...
<!-- Add this line -->
<script src="js/dist/browser-polyfill.min.js"></script>
<!-- Add this line -->
<script src="_framework/blazor.webassembly.js"></script>
</body>
</html>
With a local copy, the CSP has nothing to object to and the polyfill loads cleanly. This is a good general rule for extension development: any third party script needs to be vendored locally rather than pulled from a CDN at runtime, because the manifest’s CSP will almost always reject the remote origin.
Adjusting manifest.json for both browsers
Chrome’s manifest supports a few features that simply do not exist in Firefox, and the most common one you will run into is Declarative Content, which lets an extension automatically show or hide a page action based on the URL a tab is on. Firefox has no equivalent, so the first change is to remove the declarativeContent permission and instead list the specific domains the extension should match.
{
...
"permissions": [
"*://developer.chrome.com/*",
"*://developer.mozilla.org/*",
"*://docs.microsoft.com/*",
"activeTab",
// "declarativeContent",
"storage"
],
...
}
Next, register the polyfill script alongside the background script, so the browser namespace is available before background.js runs.
{
...
"background": {
"scripts": [
"js/dist/browser-polyfill.min.js",
"js/background.js"
],
"persistent": false
},
...
}
The manifest also needs an options_ui block in addition to the existing options_page attribute. Chrome reads options_page, but Firefox expects options_ui, and keeping both keeps the extension portable.
{
...
"options_page": "options.html",
"options_ui": {
"page": "options.html",
"browser_style": true
}
...
}
Finally, Firefox requires every extension to carry a unique identifier under browser_specific_settings, something Chrome does not ask for. Without this block, Firefox will refuse to install the extension permanently and will only allow a temporary, session based load.
{
...
"browser_specific_settings": {
"gecko": {
"id": "browser-extension-sample@devkimchi.com"
}
},
...
}
Rewriting background.js against the browser namespace
With the manifest sorted out, the JavaScript itself needs attention. The original background.js is written entirely against the chrome namespace, using the Declarative Content APIs to show the extension icon only on matching domains.
chrome.runtime.onInstalled.addListener(function() {
chrome.storage.sync.set({color: '#3aa757'}, function() {
console.log("The color is green.");
});
chrome.declarativeContent.onPageChanged.removeRules(undefined, function() {
chrome.declarativeContent.onPageChanged.addRules([{
conditions: [
new chrome.declarativeContent.PageStateMatcher({
pageUrl: { hostEquals: 'developer.chrome.com' },
}),
new chrome.declarativeContent.PageStateMatcher({
pageUrl: { hostEquals: 'docs.microsoft.com' },
})
],
actions: [new chrome.declarativeContent.ShowPageAction()]
}]);
});
});
Every chrome. reference here needs to become browser. , since declarativeContent does not exist under the browser namespace at all and has to be dropped entirely. The rewritten version below still uses the callback style at this stage, purely as an intermediate step to show the naming change on its own.
// Use 'browser.' instead of 'chrome.'
browser.runtime.onInstalled.addListener(function() {
// Use 'browser.' instead of 'chrome.'
browser.storage.sync.set({color: '#3aa757'}, function() {
console.log("The color is green.");
});
// declarativeContent has no browser.* equivalent and is removed
});
Once the naming is fixed, the callback style needs to switch to promises, because that is the actual contract the browser namespace exposes. The callback argument is simply dropped and replaced with a .then() chain.
// Before
browser.storage.sync.set({color: '#3aa757'}, function() {
console.log("The color is green.");
});
// After
browser.storage.sync.set({color: '#3aa757'})
.then(() => {
console.log("The color is green.");
});
It is worth pulling the handler out into a named function rather than leaving it anonymous inline. This makes the listener registration read cleanly and makes the function easy to unit test in isolation later.
function handleRuntimeOnInstalled(details) {
browser.storage.sync.set({color: '#3aa757'})
.then(() => {
console.log("The color is green.");
});
}
Declarative Content is gone, so the domain matching logic that used to live in the addRules call needs a new home. The pageAction API, combined with the tabs API, gives an equivalent result: query the active tab’s URL, check whether it matches one of the target domains, and show or hide the page action icon accordingly.
function handleTabs() {
browser.tabs.query({active: true, currentWindow: true})
.then((tabs) => {
console.log(tabs[0].url);
let matched = tabs[0].url.includes('developer.chrome.com')
|| tabs[0].url.includes('developer.mozilla.org')
|| tabs[0].url.includes('docs.microsoft.com');
if (matched) {
browser.pageAction.show(tabs[0].id);
} else {
browser.pageAction.hide(tabs[0].id);
}
});
}
The last piece is wiring up the listeners. handleTabs runs on three separate tab events, activated, highlighted and updated, because a user can switch context in any of those three ways and the icon needs to react each time.
browser.runtime.onInstalled.addListener(handleRuntimeOnInstalled);
browser.tabs.onActivated.addListener(handleTabs);
browser.tabs.onHighlighted.addListener(handleTabs);
browser.tabs.onUpdated.addListener(handleTabs);
A common mistake at this stage is forgetting one of the three tab events. If you only listen for onActivated, the icon will not update when the user navigates within the same tab, since that fires onUpdated instead.
Updating popup.js and options.js
popup.js reads the stored colour and applies it to the active tab when the user clicks the popup button. In its original Chrome only form it mixes callbacks with the executeScript API.
let changeColor = document.getElementById('changeColor');
chrome.storage.sync.get('color', function(data) {
changeColor.style.backgroundColor = data.color;
changeColor.setAttribute('value', data.color);
});
changeColor.onclick = function(element) {
let color = element.target.value;
chrome.tabs.query({active: true, currentWindow: true}, function(tabs) {
chrome.tabs.executeScript(
tabs[0].id,
{code: 'document.body.style.backgroundColor = "' + color + '";'});
});
};
The rewritten version swaps in the browser namespace and promise chains, and adds the same domain check used in background.js, so the script only runs against the matched tabs rather than any tab the user happens to have open.
let changeColor = document.getElementById('changeColor');
// Use 'browser.' instead of 'chrome.'
// Use the promise pattern
browser.storage.sync.get('color')
.then((data) => {
changeColor.style.backgroundColor = data.color;
changeColor.setAttribute('value', data.color);
});
changeColor.onclick = function(element) {
let color = element.target.value;
// Use 'browser.' instead of 'chrome.'
// Use the promise pattern
browser.tabs.query({active: true, currentWindow: true})
.then((tabs) => {
let matched = tabs[0].url.includes('developer.chrome.com')
|| tabs[0].url.includes('developer.mozilla.org')
|| tabs[0].url.includes('docs.microsoft.com');
if (matched) {
// Use 'browser.' instead of 'chrome.'
browser.tabs.executeScript(
tabs[0].id,
{code: 'document.body.style.backgroundColor = "' + color + '";'});
} else {
console.log('URL not matched');
}
});
};
options.js follows exactly the same pattern. It builds a row of colour buttons and saves the chosen colour to sync storage on click, and only the storage.sync.set call needs the chrome to browser rename plus the promise conversion.
let page = document.getElementById('buttonDiv');
const kButtonColors = ['#3aa757', '#e8453c', '#f9bb2d', '#4688f1'];
function constructOptions(kButtonColors) {
for (let item of kButtonColors) {
let button = document.createElement('button');
button.className = 'color-button';
button.style.backgroundColor = item;
button.style.padding = '10px';
button.addEventListener('click', function() {
// Use 'browser.' instead of 'chrome.'
// Use the promise pattern
browser.storage.sync.set({color: item})
.then(() => {
console.log('color is ' + item);
})
});
page.appendChild(button);
}
}
constructOptions(kButtonColors);
Keeping the JS interop out of every Blazor page
Both Popup.razor and Options.razor need to load the polyfill and then their own page specific script, and repeating that JSInterop boilerplate in every component gets messy fast. A cleaner approach is a shared base class that every page component inherits, with a single abstract method for the page specific script.
public class PageComponentBase : ComponentBase
{
protected abstract Task LoadAdditionalJsAsync();
}
The base class does the heavy lifting inside OnAfterRenderAsync. It imports a small JS module, loads the polyfill through that module, and then calls the abstract method so the derived page can load whatever script it specifically needs.
public class PageComponentBase : ComponentBase
{
[Inject]
private IJSRuntime JS { get; set; }
protected IJSObjectReference Module { get; private set; }
protected override async Task OnAfterRenderAsync(bool firstRender)
{
this.Module = await this.JS.InvokeAsync<IJSObjectReference>("import", "./js/main.js");
var src = "js/dist/browser-polyfill.min.js";
await this.Module.InvokeVoidAsync("loadJs", src);
// Invoke the page-specific JavaScript loader
await this.LoadAdditionalJsAsync();
}
protected abstract Task LoadAdditionalJsAsync();
}
Popup.razor then inherits PageComponentBase and only has to say which script it needs. Note that the direct IJSRuntime injection can be removed from the page itself, since the base class already owns that dependency.
@page "/popup.html"
@* @inject IJSRuntime JS *@
@using ChromeExtensionV2.Components
@inherits PageComponentBase
...
@code {
protected override async Task LoadAdditionalJsAsync()
{
var src = "js/popup.js";
await this.Module.InvokeVoidAsync("loadJs", src);
}
}
Options.razor follows the identical shape, swapping in options.js as the page specific script. This pattern scales well if you add more pages later, since each new page only contributes one line telling the base class which script to load.
@page "/options.html"
@* @inject IJSRuntime JS *@
@using ChromeExtensionV2.Components
@inherits PageComponentBase
...
@code {
protected override async Task LoadAdditionalJsAsync()
{
var src = "js/options.js";
await this.Module.InvokeVoidAsync("loadJs", src);
}
}
Packaging the build for Firefox
Chrome extensions ship as an unpacked folder or a .crx file, but Firefox specifically wants a zip archive for installation. The build’s PostBuild PowerShell script needs one extra line to compress the published output.
Compress-Archive -Path ./published/wwwroot/* -DestinationPath ./published/wwwroot/wwwroot.zip -Force
After running the build and this script, load the resulting wwwroot.zip as a temporary add-on in Firefox. The popup renders and behaves the same as it did in Chrome, which confirms the polyfill and manifest changes are working correctly.

Because of the options_ui addition to the manifest, Firefox now shows the options page as a small popup modal rather than a full browser tab, which is a nice side effect and closer to how most Firefox extensions present their settings.

Picking a new colour in that options modal updates the stored value through browser.storage.sync, and the popup picks up the change immediately, proving that the promise based storage calls are wired up correctly end to end.

What this is actually worth in practice
This series is a genuinely unusual use of Blazor WebAssembly, and it is worth being honest about where it makes sense and where it does not. If your team is already deep in the .NET ecosystem and wants to reuse C# business logic inside a browser extension, this approach avoids a full rewrite in plain JavaScript or TypeScript. But if the extension itself is simple, a thin popup and an options page, plain JavaScript with the WebExtension polyfill directly is far lighter than shipping a WASM runtime for it.
The WASM payload size is the real trade off here. A Blazor WASM extension pulls in the .NET runtime, which adds real weight to what is otherwise a tiny browser add-on, and that shows up as a slower cold start when the extension’s background or popup context first loads. For extensions with heavier logic, complex state machines, calls into shared C# libraries, or business rules you already maintain server side, that cost is easier to justify.
On the cross browser piece specifically, the manifest and polyfill changes shown here get you most of the way to Firefox compatibility, but Safari’s extension model diverges further still and typically needs its own conversion step through Apple’s Safari Web Extension Converter. If you are targeting three or more browser engines, budget separate testing time for each one rather than assuming the Firefox fixes cover Safari too. Declarative Content in particular is worth double checking on every target browser, since it is a Chrome specific feature that quietly breaks extensions when teams assume it is a web standard.
Leave a Reply