How CrxPlugin Extends VitePlugin: transformCrxManifest and renderCrxManifest Explained

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.

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 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 file
  • renderCrxManifest: Executes after the bundle generation completes, just before 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.jsgenerated-[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. 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, the core plugin responsible for manifest loading, transformation, and emission.

Transform phase execution: At line 88 of 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, 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.

// 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:

// 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:

// 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:

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 (interface) and 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. 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.

Have a question about this repo?

These articles cover the highlights, but your codebase questions are specific. Give your agent direct access to the source. Share this with your agent to get started:

Share the following with your agent to get started:
curl -s "https://instagit.com/install.md"

Works with
Claude Codex Cursor VS Code OpenClaw Any MCP Client

Maintain an open-source project? Get it listed too →