# Build-Time Process for Registering Content Scripts in Chrome Extension Manifests

> Understand the build-time process for registering content scripts in Chrome extensions. Learn how crxjs manifest plugin transforms source paths to hashed filenames for efficient bundling.

- Repository: [crxjs/chrome-extension-tools](https://github.com/crxjs/chrome-extension-tools)
- Tags: internals
- Published: 2026-02-28

---

**The build-time process for registering content scripts involves the `crx:manifest` plugin iterating over your manifest declarations, emitting each script as a Rollup chunk during the transform phase, and replacing source paths with final hashed filenames during the `generateBundle` phase.**

The CRXJS Chrome Extension Tools streamline Vite-based extension development by automating complex manifest transformations. Understanding the build-time process for registering content scripts ensures your JavaScript and CSS assets are correctly bundled and referenced in the final [`manifest.json`](https://github.com/crxjs/chrome-extension-tools/blob/main/manifest.json) that Chrome loads.

## Overview of the Build Pipeline

When you execute `vite build`, the `crx:manifest` plugin processes the `content_scripts` array from your manifest through three distinct phases. This pipeline converts developer-friendly source paths into production-ready asset references.

The entire flow is orchestrated in [`packages/vite-plugin/src/node/plugin-manifest.ts`](https://github.com/crxjs/chrome-extension-tools/blob/main/packages/vite-plugin/src/node/plugin-manifest.ts), which handles the heavy lifting of asset emission and path resolution.

## Phase 1: Manifest Transformation and Script Emission

The plugin first iterates over `manifest.content_scripts` during the transform phase. For every JavaScript file listed, it registers the entry in an internal `contentScripts` map and prepares it for bundling.

At lines 55-74 in [`plugin-manifest.ts`](https://github.com/crxjs/chrome-extension-tools/blob/main/plugin-manifest.ts), the plugin performs the following operations:

- **JavaScript chunks**: Each `js` entry is prepared for Rollup emission with `this.emitFile({ type: 'chunk' })`
- **CSS synthetic loaders**: If a content script declares `css` files, the plugin creates a virtual loader module that injects those styles at runtime
- **Metadata storage**: Every entry is stored in the `contentScripts` map with its reference ID, matches patterns, and file type information

For CSS-enabled content scripts, the plugin calls `registerContentCssEntry()` to create a synthetic loader. This virtual module ensures CSS loads before your script executes, preventing unstyled content flashes.

## Phase 2: Chunk Emission via Rollup

After registration, the plugin emits actual file chunks to Rollup's build graph. This occurs at lines 60-65 in [`plugin-manifest.ts`](https://github.com/crxjs/chrome-extension-tools/blob/main/plugin-manifest.ts):

```typescript
const refId = this.emitFile({
  type: 'chunk',
  id: join(config.root, file),   // Absolute path resolved from project root
  name: basename(file)           // Base name used for output file generation
});

```

The `emitFile` call returns a **reference ID** that the plugin stores in the `contentScripts` map. Rollup uses this reference to bundle the source file and generate a hashed filename like [`content-abc123.js`](https://github.com/crxjs/chrome-extension-tools/blob/main/content-abc123.js). The `name` parameter ensures the output file retains a recognizable base name while allowing Rollup to append content hashes for cache busting.

## Phase 3: Bundle Generation and Path Resolution

During the `generateBundle` phase (lines 18-28), the plugin walks the original `manifest.content_scripts` array and performs critical path substitution:

```typescript
manifest.content_scripts = manifest.content_scripts?.map(
  ({ js = [], ...rest }) => ({
    js: js.map(id => {
      const script = contentScripts.get(id);
      const fileName = script?.loaderName ?? script?.fileName;
      if (!fileName) throw new Error(`Content script fileName is undefined: "${id}"`);
      return fileName;
    }),
    ...rest,
  })
);

```

This transformation replaces development paths like [`src/content.ts`](https://github.com/crxjs/chrome-extension-tools/blob/main/src/content.ts) with production filenames like [`content-5a9c8e.js`](https://github.com/crxjs/chrome-extension-tools/blob/main/content-5a9c8e.js). If your entry included CSS files, the synthetic loader is automatically inserted at the front of the `js` array, guaranteeing styles inject before script execution.

## Handling CSS in Content Scripts

When a content script entry includes CSS files, CRXJS implements a specialized loading strategy. The plugin creates a **synthetic loader entry** via `registerContentCssEntry()` that acts as a virtual module.

This loader:
- Registers as a content script with `type: 'loader'`
- Receives a virtual ID and unique reference ID via `hashScriptId()`
- Gets emitted alongside regular JS chunks
- Inserts itself at the beginning of the final `js` array during `generateBundle`

The synthetic loader ensures CSS injection happens immediately when the content script matches a page, eliminating the need for manual style injection in your JavaScript code.

## Practical Implementation Example

Configure your extension with a standard manifest definition:

**[`manifest.ts`](https://github.com/crxjs/chrome-extension-tools/blob/main/manifest.ts)**

```typescript
import { defineManifest } from 'crxjs/vite-plugin';

export default defineManifest({
  manifest_version: 3,
  name: 'Demo Extension',
  version: '1.0',
  content_scripts: [
    {
      matches: ['https://example.com/*'],
      js: ['src/content.ts'],
      css: ['src/content.css']
    }
  ],
  background: { service_worker: 'src/background.ts' },
});

```

**[`vite.config.ts`](https://github.com/crxjs/chrome-extension-tools/blob/main/vite.config.ts)**

```typescript
import { defineConfig } from 'vite';
import { crx } from '@crxjs/vite-plugin';

export default defineConfig({
  plugins: [crx()],
  root: process.cwd(),
});

```

Running `vite build` executes the full registration pipeline:
1. The plugin emits [`src/content.ts`](https://github.com/crxjs/chrome-extension-tools/blob/main/src/content.ts) as a Rollup chunk and creates a synthetic CSS loader
2. Rollup bundles both assets with content hashing
3. The `generateBundle` hook rewrites the manifest entry to reference [`content-5a9c8e.js`](https://github.com/crxjs/chrome-extension-tools/blob/main/content-5a9c8e.js) (the loader that injects CSS then runs your script)
4. The transformed manifest is written to [`dist/manifest.json`](https://github.com/crxjs/chrome-extension-tools/blob/main/dist/manifest.json)

## Summary

- The `crx:manifest` plugin in [`packages/vite-plugin/src/node/plugin-manifest.ts`](https://github.com/crxjs/chrome-extension-tools/blob/main/packages/vite-plugin/src/node/plugin-manifest.ts) orchestrates the entire build-time registration process
- **Script emission** occurs via `this.emitFile({ type: 'chunk' })`, storing reference IDs for later resolution
- **Path transformation** happens during `generateBundle`, replacing source paths with final hashed filenames
- **CSS handling** uses synthetic loaders inserted at the front of the `js` array to ensure styles load before scripts execute
- The `contentScripts` map tracks metadata including reference IDs, matches patterns, and file names throughout the build process

## Frequently Asked Questions

### How does CRXJS handle CSS files in content scripts during the build?

CRXJS creates a synthetic loader entry via `registerContentCssEntry()` when it detects CSS files in a content script declaration. This virtual module is emitted as a chunk and inserted at the beginning of the final `js` array, ensuring CSS injection occurs before your JavaScript executes. The loader is registered in the `contentScripts` map with `type: 'loader'` and processed alongside regular script chunks in [`plugin-manifest.ts`](https://github.com/crxjs/chrome-extension-tools/blob/main/plugin-manifest.ts).

### What happens if a content script file path cannot be resolved during generateBundle?

The plugin throws an explicit error with the message `Content script fileName is undefined: "${id}"` if it cannot locate the emitted file name for a registered script. This validation occurs in the `generateBundle` phase when mapping source IDs to final file names, ensuring the build fails fast rather than producing an invalid manifest with broken paths.

### Is the build-time registration process different for development versus production?

The core emission logic remains consistent, but development builds skip the CSS clearing optimization. In [`plugin-manifest.ts`](https://github.com/crxjs/chrome-extension-tools/blob/main/plugin-manifest.ts), the code checks `if (config.command === 'serve')` to call `clearContentCssEntries()` during development, while production builds (`vite build`) process all content scripts through the standard emission and path replacement pipeline without clearing previous entries.

### Which source files control the content script build-time registration?

The primary logic resides in [`packages/vite-plugin/src/node/plugin-manifest.ts`](https://github.com/crxjs/chrome-extension-tools/blob/main/packages/vite-plugin/src/node/plugin-manifest.ts), which handles manifest transformation, chunk emission, and path replacement. Supporting files include [`plugin-contentScripts.ts`](https://github.com/crxjs/chrome-extension-tools/blob/main/plugin-contentScripts.ts) for development-time loader emission and [`contentScripts.ts`](https://github.com/crxjs/chrome-extension-tools/blob/main/contentScripts.ts) which defines the `RxMap` data structure storing script metadata (reference IDs, virtual IDs, and match patterns) throughout the build lifecycle.