# How CrxPlugin Extends VitePlugin: transformCrxManifest and renderCrxManifest Explained

> Learn how CrxPlugin extends VitePlugin with transformCrxManifest and renderCrxManifest hooks for advanced Chrome extension manifest manipulations. Optimize your build process.

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

---

**CrxPlugin extends Vite's standard Plugin type by adding two Chrome-extension-specific lifecycle hooks—`transformCrxManifest` for early-stage manifest mutations and `renderCrxManifest` for final asset-aware modifications before writing [`manifest.json`](https://github.com/crxjs/chrome-extension-tools/blob/main/manifest.json).**

The `CrxPlugin` interface in the **crxjs/chrome-extension-tools** repository provides a thin wrapper around Vite's native plugin system, allowing developers to treat Chrome extension manifests as first-class build assets. By extending the base `VitePlugin` type with two specialized hooks, CRXJS enables programmatic control over manifest generation at distinct phases of the Vite build lifecycle.

## What Is the CrxPlugin Interface?

`CrxPlugin` is defined in [`packages/vite-plugin/src/node/types.ts`](https://github.com/crxjs/chrome-extension-tools/blob/main/packages/vite-plugin/src/node/types.ts) as a direct extension of Vite's standard `Plugin` type. This architectural choice means all standard Vite hooks—`config`, `buildStart`, `resolveId`, `load`, and `transform`—remain fully available to Chrome extension plugin authors.

The interface augmentation introduces two optional methods specifically for manifest manipulation:

- **`transformCrxManifest`**: Executes during the Vite transform phase when processing the virtual [`crx-manifest.js`](https://github.com/crxjs/chrome-extension-tools/blob/main/crx-manifest.js) file
- **`renderCrxManifest`**: Executes after the bundle generation completes, just before [`manifest.json`](https://github.com/crxjs/chrome-extension-tools/blob/main/manifest.json) is written to disk

Because `CrxPlugin` preserves the entire Vite plugin API, existing Vite plugins work alongside CRX-specific logic without modification.

## The Two Chrome Extension Lifecycle Hooks

### transformCrxManifest: Early-Stage Manifest Transformation

The `transformCrxManifest` hook runs **before** any assets are emitted to disk. It receives a **mutable copy** of the `ManifestV3` object and can return a transformed version, `null`, or `undefined`.

**When to use it:**
- Injecting default permissions that must exist before resource generation
- Normalizing path structures across different environments
- Adding runtime flags or metadata early in the pipeline

If the hook returns a value, it replaces the current manifest for the next plugin in the execution chain. Returning `null` or `undefined` leaves the manifest unchanged and passes the existing object forward.

### renderCrxManifest: Final Asset-Aware Rendering

The `renderCrxManifest` hook executes in the `generateBundle` phase **after** all chunks and assets have been processed. It receives both the current `ManifestV3` object and the complete Rollup `bundle` object containing final filenames and hashes.

**When to use it:**
- Mapping placeholder script names to actual hashed output filenames (e.g., [`generated.js`](https://github.com/crxjs/chrome-extension-tools/blob/main/generated.js) → `generated-[hash].js`)
- Injecting content script paths that reference emitted chunk names
- Performing final validation against the actual build output

This hook returns the final manifest that gets written to [`manifest.json`](https://github.com/crxjs/chrome-extension-tools/blob/main/manifest.json). Like the transform hook, returning `null` or `undefined` preserves the current manifest state.

## How the Hooks Are Executed in the Vite Lifecycle

The hook invocations are 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), the core plugin responsible for manifest loading, transformation, and emission.

**Transform phase execution:**
At line 88 of [`plugin-manifest.ts`](https://github.com/crxjs/chrome-extension-tools/blob/main/plugin-manifest.ts), after the virtual manifest file is loaded, each registered plugin's `transformCrxManifest` method is called sequentially. This occurs within Vite's standard `transform` hook, allowing the manifest to pass through the same pipeline as other source files.

**Render phase execution:**
At line 85 of [`plugin-manifest.ts`](https://github.com/crxjs/chrome-extension-tools/blob/main/plugin-manifest.ts), once `generateBundle` finishes processing all assets, the plugins' `renderCrxManifest` hooks run in order. This timing ensures the `bundle` object contains the final, hashed filenames that will actually exist in the output directory.

```typescript
// Simplified conceptual flow from plugin-manifest.ts
async function processManifest(plugins: CrxPlugin[], manifest: ManifestV3, bundle: OutputBundle) {
  // Transform phase - line 88 region
  for (const plugin of plugins) {
    if (plugin.transformCrxManifest) {
      const result = await plugin.transformCrxManifest.call(context, manifest)
      if (result != null) manifest = result
    }
  }
  
  // ... bundle generation occurs ...
  
  // Render phase - line 85 region  
  for (const plugin of plugins) {
    if (plugin.renderCrxManifest) {
      const result = await plugin.renderCrxManifest.call(context, manifest, bundle)
      if (result != null) manifest = result
    }
  }
  
  return manifest
}

```

## Practical Implementation: Creating a Custom CrxPlugin

The following example demonstrates a complete `CrxPlugin` implementation that utilizes both hooks to manipulate a Chrome extension manifest:

```typescript
// my-crx-plugin.ts
import type { CrxPluginFn, ManifestV3 } from '@crxjs/vite-plugin'

export const myCrxPlugin: CrxPluginFn = (options) => ({
  name: 'my-crx-plugin',
  
  // Standard Vite hook remains available
  configResolved(config) {
    console.log('Vite config resolved, mode:', config.mode)
  },

  // Transform hook: Add default permissions before asset generation
  async transformCrxManifest(this, manifest: ManifestV3) {
    if (!manifest.permissions?.includes('storage')) {
      manifest.permissions = [...(manifest.permissions ?? []), 'storage']
    }
    console.log('transformCrxManifest applied')
    return manifest
  },

  // Render hook: Map generated chunks to content scripts
  async renderCrxManifest(this, manifest: ManifestV3, bundle) {
    const generatedFile = Object.values(bundle).find(
      (f) => f.type === 'chunk' && f.name === 'generated',
    )
    
    if (generatedFile && manifest.content_scripts?.[0]) {
      manifest.content_scripts[0].js = [
        ...(manifest.content_scripts[0].js ?? []),
        generatedFile.fileName
      ]
    }
    console.log('renderCrxManifest applied')
    return manifest
  },
})

```

Register the plugin alongside the core CRX plugin in your Vite configuration:

```typescript
// vite.config.ts
import { defineConfig } from 'vite'
import { crx } from '@crxjs/vite-plugin'
import { myCrxPlugin } from './my-crx-plugin'

export default defineConfig({
  plugins: [
    crx({ manifest: './src/manifest.json' }), 
    myCrxPlugin()
  ],
})

```

## Key Source Files in the Repository

Understanding the implementation requires examining these specific files in the **crxjs/chrome-extension-tools** codebase:

- **[`packages/vite-plugin/src/node/types.ts`](https://github.com/crxjs/chrome-extension-tools/blob/main/packages/vite-plugin/src/node/types.ts)** – Declares the `CrxPlugin` interface extending `VitePlugin`, including the optional `transformCrxManifest` and `renderCrxManifest` method signatures at line 53
- **[`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)** – Contains the actual hook invocation logic at lines 85 (render) and 88 (transform), handling the virtual [`crx-manifest.js`](https://github.com/crxjs/chrome-extension-tools/blob/main/crx-manifest.js) module and final [`manifest.json`](https://github.com/crxjs/chrome-extension-tools/blob/main/manifest.json) emission
- **[`tests/vite-compat/plugins/plugins.test.ts`](https://github.com/crxjs/chrome-extension-tools/blob/main/tests/vite-compat/plugins/plugins.test.ts)** – Validates hook execution during both development (`serve`) and production (`build`) modes

## Summary

- **CrxPlugin extends VitePlugin** by adding two optional Chrome extension-specific hooks while preserving all standard Vite plugin functionality
- **`transformCrxManifest`** runs during the transform phase for the virtual manifest file, receiving a mutable `ManifestV3` object for early modifications
- **`renderCrxManifest`** executes after bundle generation, receiving the manifest and Rollup bundle to perform final asset-aware adjustments
- Both hooks follow Vite's plugin convention: return a value to replace the manifest, or return `null`/`undefined` to leave it unchanged
- The implementation lives in [`packages/vite-plugin/src/node/types.ts`](https://github.com/crxjs/chrome-extension-tools/blob/main/packages/vite-plugin/src/node/types.ts) (interface) and [`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) (execution logic)

## Frequently Asked Questions

### What is the difference between transformCrxManifest and renderCrxManifest?

**`transformCrxManifest`** executes earlier in the build process, during Vite's transform phase, allowing you to modify the manifest before any assets are generated or hashed. **`renderCrxManifest`** runs later, after Rollup has generated the final bundle, giving you access to actual output filenames and hashes for accurate path injection.

### Can I use standard Vite hooks alongside CrxPlugin-specific hooks?

Yes. Because `CrxPlugin` extends the base Vite `Plugin` type, you can implement any standard Vite hook—such as `config`, `buildStart`, or `resolveId`—within the same plugin object that contains `transformCrxManifest` and `renderCrxManifest`. The CRXJS plugin system treats these as additive capabilities rather than replacements.

### What happens if I return null from transformCrxManifest or renderCrxManifest?

Returning `null` or `undefined` from either hook leaves the manifest unchanged and passes the current manifest object to the next plugin in the chain. This behavior follows Vite's established plugin pattern, where hooks only mutate state when explicitly returning a replacement value.

### Where are the CrxPlugin hook invocations implemented in the source code?

The hook execution 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). Specifically, `transformCrxManifest` is invoked around line 88 during the virtual module transform phase, and `renderCrxManifest` is called around line 85 within the `generateBundle` lifecycle hook, just before the final manifest is written to disk.