Browser extensions rarely stay simple for long.
What starts as a popup, a content script, and a few background listeners can quickly grow into a system with multiple runtime contexts, cross-context messaging, state management, browser-specific APIs, and UI surfaces like popup or DevTools.
At that point, the biggest challenge is no longer “how do I make this work?”
It becomes: how do I keep this maintainable as it grows?
That is the problem HexaJS is built to solve.
HexaJS is a new framework for modern browser extension development that brings structure to a naturally fragmented environment. It uses Dependency Injection, tokens, controllers, handlers, and a context-aware build pipeline to help teams build extensions that are easier to reason about, easier to test, and easier to scale.
Browser extensions need real architecture
A modern extension is not a single application running in one process.
It is split across isolated runtime environments:
- Background
- Content
- Managed UI such as popup and DevTools
Each context has different rules, different capabilities, and different lifecycle behavior.
If those boundaries are not reflected in the architecture, complexity spreads fast:
- background logic becomes a catch-all
- message routes turn into scattered string contracts
- browser APIs leak into too many layers
- content scripts become harder to scope and reason about
- testing becomes more difficult as dependencies become implicit
HexaJS treats those contexts as first-class architectural boundaries instead of just folders in a project.
Context-aware Dependency Injection
HexaJS uses a decorator-based DI model that works across browser extension contexts.
A service can be declared with an explicit runtime context:
import { Injectable, HexaContext } from '@hexajs-dev/common';
@Injectable({ context: HexaContext.Background })
export class TabQueryService {
getActiveIdFromTabs(tabs: Array<{ id?: number }>): number {
return tabs[0]?.id ?? -1;
}
}
This makes dependencies explicit, but more importantly, it makes context ownership explicit.
In browser extensions, background, content, and UI do not share the same runtime world. A class that is valid in one context should not automatically be available in another.
HexaJS uses its AOT pipeline to scan decorators, analyze dependency graphs, and validate those boundaries before bootstrap generation. That means dependency injection is not just a runtime convenience — it becomes part of the framework’s architectural enforcement.
Tokens for configuration and platform values
Some dependencies are services. Others are values.
Things like:
- active browser platform
- build mode
- API base URLs
- feature flags
HexaJS supports token-based injection so values can be treated as explicit dependencies too.
import { createToken, Inject, Injectable, HexaContext } from '@hexajs-dev/common';
export const API_BASE_URL = createToken(
'API_BASE_URL',
'https://api.example.com',
HexaContext.Background
);
@Injectable({ context: HexaContext.Background })
export class ApiConfigService {
constructor(@Inject(API_BASE_URL) private apiBaseUrl: string) {}
getBaseUrl(): string {
return this.apiBaseUrl;
}
}
This is especially useful in extension development because environment and platform differences are common. Making those values injectable keeps them visible, testable, and easy to override through build configuration.
HexaJS also provides built-in system tokens such as HEXA_PLATFORM and HEXA_BUILD_MODE.
Controllers for background routing
The background context usually becomes the orchestration layer of the extension. In many projects, that eventually turns into a long chain of listeners and switch statements.
HexaJS introduces Controllers to make background messaging explicit and organized.
import { Controller, Action } from '@hexajs-dev/core';
import { TabsPort } from '@hexajs-dev/ports';
@Controller({ namespace: 'tabs' })
export class TabsController {
constructor(private tabsPort: TabsPort) {}
@Action('active')
async activeTab(): Promise<{ tabId: number }> {
const tabs = await this.tabsPort.queryTabs({ active: true, currentWindow: true });
return { tabId: tabs[0]?.id ?? -1 };
}
}
This creates a clean route structure based on namespace:action, such as:
tabs:active
Instead of treating background communication as a raw transport concern, HexaJS models it as named application behavior.
That leads to code that is easier to navigate, easier to extend, and easier to test.
Handlers for content-side behavior
Content scripts have a very different responsibility from background logic. They run inside the page, interact with the DOM, and often need to be scoped to specific sites or entry points.
HexaJS introduces Handlers for content-side endpoints.
import { Handler, Handle } from '@hexajs-dev/core';
import { LoggerService } from '../services/logger.service';
import { MyContentEntry } from './content';
@Handler({ namespace: 'tabs', Contents: [MyContentEntry] })
export class TabsHandler {
constructor(private logger: LoggerService) {}
@Handle('active-id')
onActiveId(payload: { tabId: number }): { ok: boolean } {
this.logger.log('Active tab id from background:', payload.tabId);
return { ok: true };
}
}
This pattern helps keep content code narrow and intentional.
The Contents binding is particularly useful for modern extensions that target multiple websites or different page families. Instead of letting content behavior spread globally, HexaJS allows handlers to be attached to the content entries where they actually belong.
That keeps large extensions more modular as they evolve.
A better way to handle browser-specific complexity
Cross-browser support is one of the hardest parts of extension development.
Without an architectural boundary, browser API differences start leaking into business logic:
function getActiveTab() {
if (isChrome) {
chrome.tabs.query({ active: true }, (tabs) => handle(tabs));
} else if (isFirefox) {
browser.tabs.query({ active: true }).then((tabs) => handle(tabs));
} else if (isSafari) {
const tab = safari.application.activeBrowserWindow.activeTab;
handle([tab]);
}
}
HexaJS addresses this by pushing platform-specific behavior into ports and routing browser-facing orchestration through structured background controllers.
import { Controller, Action } from '@hexajs-dev/core';
import { TabsPort, StoragePort } from '@hexajs-dev/ports';
@Controller({ namespace: 'tabInfo' })
export class TabInfoController {
constructor(
private readonly tabsPort: TabsPort,
private readonly storagePort: StoragePort
) {}
@Action('current')
async onGetCurrentTabInfo(): Promise<{ tabId: number; url: string; metadata?: unknown }> {
const [activeTab] = await this.tabsPort.query({ active: true });
if (!activeTab) throw new Error('No active tab found');
const cached = await this.storagePort.get('tabMetadata', { [activeTab.id]: {} });
return {
tabId: activeTab.id,
url: activeTab.url,
metadata: cached[activeTab.id] ?? {},
};
}
}
The application layer stays focused on behavior, while browser-specific details are handled below it.
That separation becomes increasingly valuable as an extension grows across browsers and features.
Why this structure scales
The biggest scaling challenge in browser extensions is usually not runtime performance first. It is codebase complexity.
A structured architecture helps with that in very practical ways:
Better onboarding
New contributors can quickly understand where logic belongs:
- services for reusable behavior
- controllers for background entry points
- handlers for content behavior
- tokens for configuration and environment values
Easier testing
Constructor injection makes dependencies explicit and easier to mock.
Safer refactoring
When context boundaries and routes are structured, changes become easier to reason about.
Cleaner cross-context communication
Named routes are easier to track than scattered transport calls.
Better long-term maintainability
A project with clear boundaries and generated wiring stays understandable longer than one built from ad hoc listeners and utility modules.
Final thought
Browser extensions have evolved far beyond a few loose scripts.
They now behave more like distributed applications running inside isolated browser-managed environments. That reality needs stronger architecture than raw listeners, global state, and hand-wired messaging.
HexaJS is built around that idea.
By combining:
- Dependency Injection
- Tokens
- Controllers
- Handlers
- AOT analysis
- Context-aware bootstrapping
…it gives browser extension teams a foundation for building codebases that are structured from the start and ready to grow.
If you are exploring a more maintainable way to build modern browser extensions, HexaJS is now in beta and ready to try: hexajs.dev
Quick snippets
Background service
import { Injectable, HexaContext } from '@hexajs-dev/common';
@Injectable({ context: HexaContext.Background })
export class TabQueryService {
getActiveIdFromTabs(tabs: Array<{ id?: number }>): number {
return tabs[0]?.id ?? -1;
}
}
Token injection
import { Inject, Injectable, HEXA_PLATFORM } from '@hexajs-dev/common';
@Injectable()
export class PlatformLabelService {
constructor(@Inject(HEXA_PLATFORM) private platform: string) {}
getLabel(): string {
return `running on ${this.platform}`;
}
}
Background controller
import { Controller, Action } from '@hexajs-dev/core';
import { TabsPort } from '@hexajs-dev/ports';
@Controller({ namespace: 'tabs' })
export class TabsController {
constructor(private tabsPort: TabsPort) {}
@Action('active')
async activeTab(): Promise<{ tabId: number }> {
const tabs = await this.tabsPort.queryTabs({ active: true, currentWindow: true });
return { tabId: tabs[0]?.id ?? -1 };
}
}
Content handler
import { Handler, Handle } from '@hexajs-dev/core';
import { MyContentEntry } from './content';
@Handler({ namespace: 'tabs', Contents: [MyContentEntry] })
export class TabsHandler {
@Handle('active-id')
onActiveId(payload: { tabId: number }): { ok: boolean } {
return { ok: true };
}
}

Top comments (0)