Guide Environment API for Frameworks :::info Release Candidate The Environment API is generally in the release candidate phase. We'll maintain stability in the APIs between major releases to allow the ecosystem to experiment and build upon them. However, note that some specific APIs are still considered experimental. We plan to stabilize these new APIs (with potential breaking changes) in a future major release once downstream projects have had time to experiment with the new features and validate them. Resources: Feedback discussion where we are gathering feedback about the new APIs. Environment API PR where the new APIs were implemented and reviewed. Please share your feedback with us. ::: DevEnvironment Communication Levels Since environments may run in different runtimes, communication against the environment may have constraints depending on the runtime. To allow frameworks to write runtime agnostic code easily, the Environment API provides three kinds of communication levels. RunnableDevEnvironment RunnableDevEnvironment is an environment that can communicate arbitrary JavaScript values with your application code. Importing a module returns its real, live exports (functions, class instances, and any other values), so frameworks can run their server entries directly. The implicit ssr environment and other non-client environments use a RunnableDevEnvironment by default during dev. You can guard access to the runner with the isRunnableDevEnvironment function. Its runner is a ModuleRunner. You import modules through it with runner.import(url), which fetches, transforms, and evaluates a module from the Vite module graph (the url accepts a file path, server path, or id relative to the root) and returns the instantiated module with full HMR support. It is the modern replacement for server.ssrLoadModule, so frameworks can migrate to it to enable HMR for their SSR dev story. :::info Why it can communicate arbitrary values A RunnableDevEnvironment evaluates modules in the same runtime as the Vite server, so values cross the boundary in-process instead of being serialized. This is what distinguishes it from FetchableDevEnvironment, which can only communicate through serialized Request/ Response objects over the Fetch API. As a result, using a RunnableDevEnvironment requires the runner's runtime to be the same as the one the Vite server is running in. ::: export class RunnableDevEnvironment extends DevEnvironment { public readonly runner: ModuleRunner } class ModuleRunner { /** * URL to execute. * Accepts file path, server path, or id relative to the root. * Returns an instantiated module (same as in ssrLoadModule) */ public async import(url: string): Promise> /** * Other ModuleRunner methods... */ } if (isRunnableDevEnvironment(server.environments.ssr)) { await server.environments.ssr.runner.import('/entry-point.js') } :::warning The runner is evaluated lazily only when it's accessed for the first time. Beware that Vite enables source map support when the runner is created by calling process.setSourceMapsEnabled or by overriding Error.prepareStackTrace if it's not available. ::: Given a Vite server configured in middleware mode as described by the SSR setup guide, let's implement the SSR middleware using the environment API. Remember that it doesn't have to be called ssr, so we'll name it server in this example. Error handling is omitted. import fs from 'node:fs' import path from 'node:path' import { createServer } from 'vite' const viteServer = await createServer({ server: { middlewareMode: true }, appType: 'custom', environments: { server: { // by default, modules are run in the same process as the vite server }, }, }) // You might need to cast this to RunnableDevEnvironment in TypeScript or // use isRunnableDevEnvironment to guard the access to the runner const serverEnvironment = viteServer.environments.server app.use('*', async (req, res, next) => { const url = req.originalUrl // 1. Read index.html const indexHtmlPath = path.resolve(import.meta.dirname, 'index.html') let template = fs.readFileSync(indexHtmlPath, 'utf-8') // 2. Apply Vite HTML transforms. This injects the Vite HMR client, // and also applies HTML transforms from Vite plugins, e.g. global // preambles from @vitejs/plugin-react template = await viteServer.transformIndexHtml(url, template) // 3. Load the server entry. import(url) automatically transforms // ESM source code to be usable in Node.js! There is no bundling // required, and provides full HMR support. const { render } = await serverEnvironment.runner.import( '/src/entry-server.js', ) // 4. render the app HTML. This assumes entry-server.js's exported // `render` function calls appropriate framework SSR APIs, // e.g. ReactDOMServer.renderToString() const appHtml = await render(url) // 5. Inject the app-rendered HTML into the template. const html = template.replace(``, appHtml) // 6. Send the rendered HTML back. res.status(200).set({ 'Content-Type': 'text/html' }).end(html) }) When using environments that support HMR (such as RunnableDevEnvironment), you should add import.meta.hot.accept() in your server entry file for optimal behavior. Without this, server file changes will invalidate the entire server module graph: // src/entry-server.js export function render(...) { ... } if (import.meta.hot) { import.meta.hot.accept() } FetchableDevEnvironment :::info We are looking for feedback on the FetchableDevEnvironment proposal. ::: FetchableDevEnvironment is an environment that can communicate with its runtime via the Fetch API interface. Since the RunnableDevEnvironment is only possible to implement in a limited set of runtimes, we recommend to use the FetchableDevEnvironment instead of the RunnableDevEnvironment. A common reason to reach for it is a framework that wants to support a runtime that can't run Vite directly (e.g. Cloudflare Workers). A RunnableDevEnvironment can't be used there, since it requires the runner to share the Vite server's runtime so values can cross the boundary in-process. Standardizing on the Fetch API lets the framework keep a single request-handling path across all of its target runtimes: its dev middleware forwards each incoming browser request as a Request and sends the returned Response back to the browser, mirroring how the app handles requests in production. This environment provides a standardized way of handling requests via the handleRequest method: import { createServer, createFetchableDevEnvironment, isFetchableDevEnvironment, } from 'vite' const server = await createServer({ server: { middlewareMode: true }, appType: 'custom', environments: { custom: { dev: { createEnvironment(name, config) { return createFetchableDevEnvironment(name, config, { handleRequest(request: Request): Promise | Response { // handle Request and return a Response }, }) }, }, }, }, }) // Any consumer of the environment API can now call `dispatchFetch` if (isFetchableDevEnvironment(server.environments.custom)) { const response: Response = await server.environments.custom.dispatchFetch( new Request('http://example.com/request-to-handle'), ) } :::warning Vite validates the input and output of the dispatchFetch method: the request must be an instance of the global Request class and the response must be the instance of the global Response class. Vite will throw a TypeError if this is not the case. Note that although the FetchableDevEnvironment is implemented as a class, it is considered an implementation detail by the Vite team and might change at any moment. ::: raw DevEnvironment If the environment does not implement the RunnableDevEnvironment or FetchableDevEnvironment interfaces, you need to set up the communication manually. If your code can run in the same runtime as the user modules (i.e., it does not rely on Node.js-specific APIs), you can use a virtual module. This approach eliminates the need to access the value from the code using Vite's APIs. // code using the Vite's APIs import { createServer } from 'vite' const server = await createServer({ plugins: [ // a plugin that handles `virtual:entrypoint` { name: 'virtual-module', /* plugin implementation */ }, ], }) const ssrEnvironment = server.environments.ssr const input = {} // use exposed functions by each environment factories that runs the code // check for each environment factories what they provide if (ssrEnvironment instanceof CustomDevEnvironment) { ssrEnvironment.runEntrypoint('virtual:entrypoint') } else { throw new Error(`Unsupported runtime for ${ssrEnvironment.name}`) } // ------------------------------------- // virtual:entrypoint const { createHandler } = await import('./entrypoint.js') const handler = createHandler(input) const response = handler(new Request('http://example.com/')) // ------------------------------------- // ./entrypoint.js export function createHandler(input) { return function handler(req) { return new Response('hello') } } For example, to call transformIndexHtml on the user module, the following plugin can be used: function vitePluginVirtualIndexHtml(): Plugin { let server: ViteDevServer | undefined return { name: vitePluginVirtualIndexHtml.name, configureServer(server_) { server = server_ }, resolveId(source) { return source === 'virtual:index-html' ? '\0' + source : undefined }, async load(id) { if (id === '\0' + 'virtual:index-html') { let html: string if (server) { this.addWatchFile('index.html') html = fs.readFileSync('index.html', 'utf-8') html = await server.transformIndexHtml('/', html) } else { html = fs.readFileSync('dist/client/index.html', 'utf-8') } return `export default ${JSON.stringify(html)}` } return }, } } If your code requires Node.js APIs, you can use hot.send to communicate with the code that uses Vite's APIs from the user modules. However, be aware that this approach may not work the same way after the build process. // code using the Vite's APIs import { createServer } from 'vite' const server = await createServer({ plugins: [ // a plugin that handles `virtual:entrypoint` { name: 'virtual-module', /* plugin implementation */ }, ], }) const ssrEnvironment = server.environments.ssr const input = {} // use exposed functions by each environment factories that runs the code // check for each environment factories what they provide if (ssrEnvironment instanceof RunnableDevEnvironment) { await ssrEnvironment.runner.import('virtual:entrypoint') } else if (ssrEnvironment instanceof CustomDevEnvironment) { ssrEnvironment.runEntrypoint('virtual:entrypoint') } else { throw new Error(`Unsupported runtime for ${ssrEnvironment.name}`) } const req = new Request('http://example.com/') const uniqueId = 'a-unique-id' ssrEnvironment.hot.send('request', serialize({ req, uniqueId })) const response = await new Promise((resolve) => { ssrEnvironment.hot.on('response', (data) => { data = deserialize(data) if (data.uniqueId === uniqueId) { resolve(data.res) } }) }) // ------------------------------------- // virtual:entrypoint const { createHandler } = await import('./entrypoint.js') const handler = createHandler(input) import.meta.hot.on('request', (data) => { const { req, uniqueId } = deserialize(data) const res = handler(req) import.meta.hot.send('response', serialize({ res: res, uniqueId })) }) // ------------------------------------- // ./entrypoint.js export function createHandler(input) { return function handler(req) { return new Response('hello') } } Environments During Build In the CLI, calling vite build and vite build --ssr will still build the client only and ssr only environments for backward compatibility. When the builder option is set (even to an empty object {}, which is what vite build --app does), vite build opts in to building the entire app instead. This will become the default in a future major. In this mode, Vite creates a ViteBuilder instance (the build-time equivalent of a ViteDevServer) and uses it to build all configured environments for production. By default, environments are built in series, following the order of the environments record. Configuring the app build with builder.buildApp A framework or user can control how the environments are built through the builder.buildApp option. It receives the ViteBuilder instance (named builder in the example below) and is responsible for building each environment; for instance, to build some of them in parallel: import { defineConfig } from 'vite' export default defineConfig({ builder: { buildApp: async (builder) => { const environments = Object.values(builder.environments) await Promise.all( environments.map((environment) => builder.build(environment)), ) }, }, }) The buildApp plugin hook Type: (this: MinimalPluginContextWithoutEnvironment, builder: ViteBuilder) => Promise Kind: async, sequential Scope: Global Besides the builder.buildApp config option, plugins can define a buildApp hook to participate in the app build. The config option and the plugin hooks run in a defined order: hooks with order 'pre' or null run first, then the configured builder.buildApp, then hooks with order 'post'. Within a hook, environment.isBuilt tells you whether an environment has already been built, which lets a plugin avoid building it twice. Building programmatically with createBuilder To trigger an app build from your own code, use createBuilder instead of the standalone build function. createBuilder is the build-time equivalent of createServer: it resolves the config and returns a ViteBuilder, whose buildApp method builds every configured environment. You can also build a single environment with builder.build(environment). import { createBuilder } from 'vite' const builder = await createBuilder() await builder.buildApp() createBuilder supersedes the standalone build function for environment-aware builds. build still works as the simple entry point for the legacy client-only and ssr-only builds described above, but it cannot build arbitrary environments. Running builder.buildApp() is the programmatic equivalent of vite build --app. Environment Agnostic Code Most of the time, the current environment instance will be available as part of the context of the code being run so the need to access them through server.environments should be rare. For example, inside plugin hooks the environment is exposed as part of the PluginContext, so it can be accessed using this.environment. See Environment API for Plugins to learn about how to build environment aware plugins. Using `Environment` Instances :::info Release Candidate The Environment API is generally in the release candidate phase. We'll maintain stability in the APIs between major releases to allow the ecosystem to experiment and build upon them. However, note that some specific APIs are still considered experimental. We plan to stabilize these new APIs (with potential breaking changes) in a future major release once downstream projects have had time to experiment with the new features and validate them. Resources: Feedback discussion where we are gathering feedback about the new APIs. Environment API PR where the new APIs were implemented and reviewed. Please share your feedback with us. ::: Accessing the Environments During dev, the available environments in a dev server can be accessed using server.environments: // create the server, or get it from the configureServer hook const server = await createServer(/* options */) const clientEnvironment = server.environments.client clientEnvironment.transformRequest(url) console.log(server.environments.ssr.moduleGraph) You can also access the current environment from plugins. See the Environment API for Plugins for more details. DevEnvironment class During dev, each environment is an instance of the DevEnvironment class: class DevEnvironment { /** * Unique identifier for the environment in a Vite server. * By default Vite exposes 'client' and 'ssr' environments. */ name: string /** * Communication channel to send and receive messages from the * associated module runner in the target runtime. */ hot: NormalizedHotChannel /** * Graph of module nodes, with the imported relationship between * processed modules and the cached result of the processed code. */ moduleGraph: EnvironmentModuleGraph /** * Resolved plugins for this environment, including the ones * created using the per-environment `create` hook */ plugins: Plugin[] /** * Allows to resolve, load, and transform code through the * environment plugins pipeline */ pluginContainer: EnvironmentPluginContainer /** * Resolved config options for this environment. Options at the server * global scope are taken as defaults for all environments, and can * be overridden (resolve conditions, external, optimizedDeps) */ config: ResolvedConfig & ResolvedDevEnvironmentOptions constructor( name: string, config: ResolvedConfig, context: DevEnvironmentContext, ) /** * Resolve the URL to an id, load it, and process the code using the * plugins pipeline. The module graph is also updated. */ async transformRequest(url: string): Promise /** * Register a request to be processed with low priority. This is useful * to avoid waterfalls. The Vite server has information about the * imported modules by other requests, so it can warmup the module graph * so the modules are already processed when they are requested. */ async warmupRequest(url: string): Promise /** * Called by the module runner to retrieve information about the specified * module. Internally calls `transformRequest` and wraps the result in the * format that the module runner understands. * This method is not meant to be called manually. */ async fetchModule( id: string, importer?: string, options?: FetchFunctionOptions, ): Promise } With DevEnvironmentContext being: interface DevEnvironmentContext { hot: boolean transport?: HotChannel | WebSocketServer options?: EnvironmentOptions remoteRunner?: { inlineSourceMap?: boolean } depsOptimizer?: DepsOptimizer } and with TransformResult being: interface TransformResult { code: string map: SourceMap | { mappings: '' } | null etag?: string deps?: string[] dynamicDeps?: string[] } An environment instance in the Vite server lets you process a URL using the environment.transformRequest(url) method. This function will use the plugin pipeline to resolve the url to a module id, load it (reading the file from the file system or through a plugin that implements a virtual module), and then transform the code. While transforming the module, imports and other metadata will be recorded in the environment module graph by creating or updating the corresponding module node. When processing is done, the transform result is also stored in the module. :::info transformRequest naming We are using transformRequest(url) and warmupRequest(url) in the current version of this proposal so it is easier to discuss and understand for users used to Vite's current API. Before releasing, we can take the opportunity to review these names too. For example, it could be named environment.processModule(url) or environment.loadModule(url) taking a page from Rollup's context.load(id) in plugin hooks. For the moment, we think keeping the current names and delaying this discussion is better. ::: Separate Module Graphs Each environment has an isolated module graph. All module graphs have the same signature, so generic algorithms can be implemented to crawl or query the graph without depending on the environment. hotUpdate is a good example. When a file is modified, the module graph of each environment will be used to discover the affected modules and perform HMR for each environment independently. ::: info Vite v5 had a mixed Client and SSR module graph. Given an unprocessed or invalidated node, it isn't possible to know if it corresponds to the Client, SSR, or both environments. Module nodes have some properties prefixed, like clientImportedModules and ssrImportedModules (and importedModules that returns the union of both). importers contains all importers from both the Client and SSR environment for each module node. A module node also has transformResult and ssrTransformResult. A backward compatibility layer allows the ecosystem to migrate from the deprecated server.moduleGraph. ::: Each module is represented by a EnvironmentModuleNode instance. Modules may be registered in the graph without yet being processed ( transformResult would be null in that case). importers and importedModules are also updated after the module is processed. class EnvironmentModuleNode { environment: string url: string id: string | null = null file: string | null = null type: 'js' | 'css' importers = new Set() importedModules = new Set() importedBindings: Map> | null = null info?: ModuleInfo meta?: Record transformResult: TransformResult | null = null acceptedHmrDeps = new Set() acceptedHmrExports: Set | null = null isSelfAccepting?: boolean lastHMRTimestamp = 0 lastInvalidationTimestamp = 0 } environment.moduleGraph is an instance of EnvironmentModuleGraph: export class EnvironmentModuleGraph { environment: string urlToModuleMap = new Map() idToModuleMap = new Map() etagToModuleMap = new Map() fileToModulesMap = new Map>() constructor( environment: string, resolveId: (url: string) => Promise, ) async getModuleByUrl( rawUrl: string, ): Promise getModuleById(id: string): EnvironmentModuleNode | undefined getModulesByFile(file: string): Set | undefined onFileChange(file: string): void onFileDelete(file: string): void invalidateModule( mod: EnvironmentModuleNode, seen: Set = new Set(), timestamp: number = monotonicDateNow(), isHmr: boolean = false, ): void invalidateAll(): void async ensureEntryFromUrl( rawUrl: string, setIsSelfAccepting = true, ): Promise createFileOnlyEntry(file: string): EnvironmentModuleNode async resolveUrl(url: string): Promise updateModuleTransformResult( mod: EnvironmentModuleNode, result: TransformResult | null, ): void getModuleByEtag(etag: string): EnvironmentModuleNode | undefined } FetchResult The environment.fetchModule method returns a FetchResult that is meant to be consumed by the module runner. FetchResult is a union of CachedFetchResult, ExternalFetchResult, and ViteFetchResult. CachedFetchResult is analogous to the 304 (Not Modified) HTTP status code. export interface CachedFetchResult { /** * If the module is cached in the runner, this confirms * it was not invalidated on the server side. */ cache: true } ExternalFetchResult instructs the module runner to import the module using the runExternalModule method on the ModuleEvaluator. In this case, the default module evaluator will use the runtime's native import instead of processing the file through Vite. export interface ExternalFetchResult { /** * The path to the externalized module starting with file://. * By default this will be imported via a dynamic "import" * instead of being transformed by Vite and loaded with the Vite runner. */ externalize: string /** * Type of the module. Used to determine if the import statement is correct. * For example, if Vite needs to throw an error if a variable is not actually exported. */ type: 'module' | 'commonjs' | 'builtin' | 'network' } ViteFetchResult returns information about the current module, including the code to execute and the module's id, file, and url. The invalidate field instructs the module runner to invalidate the module before executing it again rather than serving it from cache. This is usually true when an HMR update was triggered. export interface ViteFetchResult { /** * Code that will be evaluated by the Vite runner. * By default this will be wrapped in an async function. */ code: string /** * File path of the module on disk. * This will be resolved as import.meta.url/filename. * Will be `null` for virtual modules. */ file: string | null /** * Module ID in the server module graph. */ id: string /** * Module URL used in the import. */ url: string /** * Invalidate module on the client side. */ invalidate: boolean } Environment API for Plugins :::info Release Candidate The Environment API is generally in the release candidate phase. We'll maintain stability in the APIs between major releases to allow the ecosystem to experiment and build upon them. However, note that some specific APIs are still considered experimental. We plan to stabilize these new APIs (with potential breaking changes) in a future major release once downstream projects have had time to experiment with the new features and validate them. Resources: Feedback discussion where we are gathering feedback about the new APIs. Environment API PR where the new APIs were implemented and reviewed. Please share your feedback with us. ::: Per-environment Hooks and Global Hooks Plugins run on a shared pipeline, but their hooks fall into two categories depending on whether they run once for the whole server or once for each environment. Global hooks are called a single time, independent of the configured environments. They handle app-wide concerns such as resolving the config or setting up the dev and preview servers, so this.environment is not relevant to them. Config resolution related hooks and server related hooks are global hooks. Per-environment hooks are called once for each environment, and expose the current environment through this.environment in their context. All Rolldown hooks are per-environment, as are other Vite-specific hooks that handle modules. However, note that buildStart and buildEnd are only called for the client environment without the perEnvironmentStartEndDuringDev: true flag. Accessing the Current Environment in Hooks Given that there were only two Environments until Vite 6 ( client and ssr), a ssr boolean was enough to identify the current environment in Vite APIs. Plugin Hooks received a ssr boolean in the last options parameter, and several APIs expected an optional last ssr parameter to properly associate modules to the correct environment (for example server.moduleGraph.getModuleByUrl(url, { ssr })). With the advent of configurable environments, we now have a uniform way to access their options and instance in plugins. Plugin hooks now expose this.environment in their context, and APIs that previously expected a ssr boolean are now scoped to the proper environment (for example environment.moduleGraph.getModuleByUrl(url)). The Vite server has a shared plugin pipeline, but when a module is processed it is always done in the context of a given environment. The environment instance is available in the plugin context. A plugin could use the environment instance to change how a module is processed depending on the configuration for the environment (which can be accessed using environment.config). transform(code, id) { console.log(this.environment.config.resolve.conditions) } Registering New Environments Using Hooks Plugins can add new environments in the config hook. For example, RSC support uses an additional environment to have a separate module graph with the react-server condition: config(config: UserConfig) { return { environments: { rsc: { resolve: { conditions: ['react-server', ...defaultServerConditions], }, }, }, } } An empty object is enough to register the environment, using default values from the root level environment config. Configuring Environment Using the configEnvironment Hook Type: (name: string, config: EnvironmentOptions, env: { mode: string, command: 'build' | 'serve', isSsrBuild?: boolean, isPreview?: boolean, isSsrTargetWebworker?: boolean }) => EnvironmentOptions | null | void Kind: async, sequential Scope: Per-environment While the config hook is running, the complete list of environments isn't yet known and the environments can be affected by both the default values from the root level environment config or explicitly through the config.environments record. Plugins should set default values using the config hook. To configure each environment, they can use the new configEnvironment hook. This hook is called for each environment with its partially resolved config including resolution of final defaults. configEnvironment(name: string, options: EnvironmentOptions) { // add "workerd" condition to the rsc environment if (name === 'rsc') { return { resolve: { conditions: ['workerd'], }, } } } The hotUpdate Hook Type: (this: { environment: DevEnvironment }, options: HotUpdateOptions) => Array | void | Promise | void> Kind: async, sequential Scope: Per-environment See also: HMR API The hotUpdate hook allows plugins to perform custom HMR update handling for a given environment. When a file changes, the HMR algorithm is run for each environment in series according to the order in server.environments, so the hotUpdate hook will be called multiple times. The hook receives a context object with the following signature: interface HotUpdateOptions { type: 'create' | 'update' | 'delete' file: string timestamp: number modules: Array read: () => string | Promise server: ViteDevServer } this.environment is the module execution environment where a file update is currently being processed. modules is an array of modules in this environment that are affected by the changed file. It's an array because a single file may map to multiple served modules (e.g. Vue SFCs). read is an async read function that returns the content of the file. This is provided because on some systems, the file change callback may fire too fast before the editor finishes updating the file, and direct fs.readFile will return empty content. The read function passed in normalizes this behavior. The hook can choose to: Filter and narrow down the affected module list so that the HMR is more accurate. Return an empty array and perform a full reload: hotUpdate({ modules, timestamp }) { if (this.environment.name !== 'client') return // Invalidate modules manually const invalidatedModules = new Set() for (const mod of modules) { this.environment.moduleGraph.invalidateModule( mod, invalidatedModules, timestamp, true ) } this.environment.hot.send({ type: 'full-reload' }) return [] } Return an empty array and perform complete custom HMR handling by sending custom events to the client: hotUpdate() { if (this.environment.name !== 'client') return this.environment.hot.send({ type: 'custom', event: 'special-update', data: {} }) return [] } Client code should register the corresponding handler using the HMR API (this could be injected by the same plugin's transform hook): if (import.meta.hot) { import.meta.hot.on('special-update', (data) => { // perform custom update }) } Per-environment State in Plugins Given that the same plugin instance is used for different environments, the plugin state needs to be keyed with this.environment. This is the same pattern the ecosystem has already been using to keep state about modules using the ssr boolean as key to avoid mixing client and ssr modules state. A Map can be used to keep the state for each environment separately. Note that for backward compatibility, buildStart and buildEnd are only called for the client environment without the perEnvironmentStartEndDuringDev: true flag. Same for watchChange and the perEnvironmentWatchChangeDuringDev: true flag. function PerEnvironmentCountTransformedModulesPlugin() { const state = new Map() return { name: 'count-transformed-modules', perEnvironmentStartEndDuringDev: true, buildStart() { state.set(this.environment, { count: 0 }) }, transform(id) { state.get(this.environment).count++ }, buildEnd() { console.log(this.environment.name, state.get(this.environment).count) } } } Per-environment Plugins Using the applyToEnvironment Hook Type: (environment: PartialEnvironment) => boolean | PluginOption | Promise Kind: async, sequential Scope: Per-environment A plugin can define what are the environments it should apply to with the applyToEnvironment function. const UnoCssPlugin = () => { // shared global state return { buildStart() { // init per-environment state with WeakMap // using this.environment }, configureServer() { // use global hooks normally }, applyToEnvironment(environment) { // return true if this plugin should be active in this environment, // or return a new plugin to replace it. // if the hook is not used, the plugin is active in all environments }, resolveId(id, importer) { // only called for environments this plugin apply to }, } } If a plugin isn't environment aware and has state that isn't keyed on the current environment, the applyToEnvironment hook allows to easily make it per-environment. import { nonShareablePlugin } from 'non-shareable-plugin' export default defineConfig({ plugins: [ { name: 'per-environment-plugin', applyToEnvironment(environment) { return nonShareablePlugin({ outputName: environment.name }) }, }, ], }) Vite exports a perEnvironmentPlugin helper to simplify these cases where no other hooks are required: import { nonShareablePlugin } from 'non-shareable-plugin' export default defineConfig({ plugins: [ perEnvironmentPlugin('per-environment-plugin', (environment) => nonShareablePlugin({ outputName: environment.name }), ), ], }) Note that the plugin returned from applyToEnvironment or perEnvironmentPlugin should not use Vite-specific hooks. The applyToEnvironment hook is called at config time, currently after configResolved due to projects in the ecosystem modifying the plugins in it. Environment plugins resolution may be moved before configResolved in the future. Application-Plugin Communication environment.hot allows plugins to communicate with the code on the application side for a given environment. This is the equivalent of the Client-server Communication feature, but supports environments other than the client environment. :::warning Note Note that this feature is only available for environments that support HMR. ::: Managing the Application Instances Be aware that there might be multiple application instances running in the same environment. For example, if you have multiple tabs open in the browser, each tab is a separate application instance and has a separate connection to the server. When a new connection is established, a vite:client:connect event is emitted on the environment's hot instance. When the connection is closed, a vite:client:disconnect event is emitted. Each event handler receives the NormalizedHotChannelClient as the second argument. The client is an object with a send method that can be used to send messages to that specific application instance. The client reference is always the same for the same connection, so you can keep it to track the connection. Example Usage The plugin side: configureServer(server) { server.environments.ssr.hot.on('my:greetings', (data, client) => { // do something with the data, // and optionally send a response to that application instance client.send('my:foo:reply', `Hello from server! You said: ${data}`) }) // broadcast a message to all application instances server.environments.ssr.hot.send('my:foo', 'Hello from server!') } The application side is same with the Client-server Communication feature. You can use the import.meta.hot object to send messages to the plugin. Environment in Build Hooks In the same way as during dev, plugin hooks also receive the environment instance during build, replacing the ssr boolean. This also works for renderChunk, generateBundle, and other build only hooks. Shared Plugins During Build Before Vite 6, the plugins pipelines worked in a different way during dev and build: During dev: plugins are shared During Build: plugins are isolated for each environment (in different processes: vite build then vite build --ssr). This forced frameworks to share state between the client build and the ssr build through manifest files written to the file system. In Vite 6, we are now building all environments in a single process so the way the plugins pipeline and inter-environment communication can be aligned with dev. In a future major, we could have complete alignment: During both dev and build: plugins are shared, with per-environment filtering There will also be a single ResolvedConfig instance shared during build, allowing for caching at entire app build process level in the same way as we have been doing with WeakMap during dev. For Vite 6, we need to do a smaller step to keep backward compatibility. Ecosystem plugins are currently using config.build instead of environment.config.build to access configuration, so we need to create a new ResolvedConfig per-environment by default. A project can opt-in into sharing the full config and plugins pipeline setting builder.sharedConfigBuild to true. This option would only work of a small subset of projects at first, so plugin authors can opt-in for a particular plugin to be shared by setting the sharedDuringBuild flag to true. This allows for easily sharing state both for regular plugins: function myPlugin() { // Share state among all environments in dev and build const sharedState = ... return { name: 'shared-plugin', transform(code, id) { ... }, // Opt-in into a single instance for all environments sharedDuringBuild: true, } } Environment API for Runtimes :::info Release Candidate The Environment API is generally in the release candidate phase. We'll maintain stability in the APIs between major releases to allow the ecosystem to experiment and build upon them. However, note that some specific APIs are still considered experimental. We plan to stabilize these new APIs (with potential breaking changes) in a future major release once downstream projects have had time to experiment with the new features and validate them. Resources: Feedback discussion where we are gathering feedback about the new APIs. Environment API PR where the new APIs were implemented and reviewed. Please share your feedback with us. ::: This page is for runtime providers, authors who integrate a JavaScript runtime with Vite. A runtime here is the JavaScript engine where transformed code executes, such as Node.js, the browser, Cloudflare's workerd, or a Worker thread. A runtime provider packages the integration for one of these runtimes, so framework authors and end users (the developers building an app) don't have to set it up themselves. Environment Factories Environment factories are intended to be implemented by runtime providers, not by end users. Environment factories return an EnvironmentOptions for the most common case of using the target runtime for both dev and build environments. The default environment options can also be set so the user doesn't need to do it. function createWorkerdEnvironment( userConfig: EnvironmentOptions, ): EnvironmentOptions { return mergeConfig( { resolve: { conditions: [/*...*/], }, dev: { createEnvironment(name, config) { return createWorkerdDevEnvironment(name, config, { hot: true, transport: customHotChannel(), }) }, }, build: { createEnvironment(name, config) { return createWorkerdBuildEnvironment(name, config) }, }, }, userConfig, ) } Then the config file can be written as: import { createWorkerdEnvironment } from 'vite-environment-workerd' export default { environments: { ssr: createWorkerdEnvironment({ build: { outDir: '/dist/ssr', }, }), rsc: createWorkerdEnvironment({ build: { outDir: '/dist/rsc', }, }), }, } and frameworks can use an environment with the workerd runtime to do SSR using: const ssrEnvironment = server.environments.ssr Creating a New Environment Factory A Vite dev server exposes two environments by default: a client environment and an ssr environment. The client environment is a browser environment by default, and the module runner is implemented by importing the virtual module /@vite/client to client apps. The SSR environment runs in the same Node runtime as the Vite server by default and allows application servers to be used to render requests during dev with full HMR support. The transformed source code is called a module, and the relationships between the modules processed in each environment are kept in a module graph. The transformed code for these modules is sent to the runtimes associated with each environment to be executed. When a module is evaluated in the runtime, its imported modules will be requested triggering the processing of a section of the module graph. A Vite Module Runner allows running any code by processing it with Vite plugins first. It is different from server.ssrLoadModule because the runner implementation is decoupled from the server. This allows library and framework authors to implement their layer of communication between the Vite server and the runner. The browser communicates with its corresponding environment using the server WebSocket and through HTTP requests. The Node Module runner can directly do function calls to process modules as it is running in the same process. Other environments could run modules connecting to a JS runtime like workerd, or a Worker Thread as Vitest does. digraph module_runner { rankdir=LR node [shape=box style="rounded,filled" fontname="Arial" fontsize=11 margin="0.2,0.1" fontcolor="${#3c3c43|#ffffff}" color="${#c2c2c4|#3c3f44}"] edge [color="${#67676c|#98989f}" fontname="Arial" fontsize=10 fontcolor="${#67676c|#98989f}"] bgcolor="transparent" compound=true subgraph cluster_server { label="Vite Dev Server (Node.js)" labeljust=l fontname="Arial" fontsize=12 style="rounded,filled" fillcolor="${#f6f6f7|#1a1a1f}" color="${#c2c2c4|#3c3f44}" fontcolor="${#3c3c43|#ffffff}" subgraph cluster_env { label="DevEnvironment" labeljust=l fontname="Arial" fontsize=11 style="rounded,filled" fillcolor="${#f2ecfc|#2c273e}" color="${#c2c2c4|#3c3f44}" fontcolor="${#3c3c43|#ffffff}" plugins [label="Plugin\nPipeline" fillcolor="${#e9eaff|#222541}"] mg [label="Module\nGraph" fillcolor="${#e9eaff|#222541}"] hot [label="HotChannel" fillcolor="${#fcf4dc|#38301a}"] plugins -> mg [dir=both] mg -> hot [style=invis] } } subgraph cluster_runtime { label="Target Runtime" labeljust=l fontname="Arial" fontsize=12 style="rounded,filled" fillcolor="${#f0fdf4|#131b15}" color="${#c2c2c4|#3c3f44}" fontcolor="${#3c3c43|#ffffff}" subgraph cluster_runner { label="ModuleRunner" labeljust=l fontname="Arial" fontsize=11 style="rounded,filled" fillcolor="${#def5ed|#15312d}" color="${#c2c2c4|#3c3f44}" fontcolor="${#3c3c43|#ffffff}" evaluator [label="Module\nEvaluator" fillcolor="${#def5ed|#15312d}"] transport [label="Transport" fillcolor="${#fcf4dc|#38301a}"] } } hot -> transport [label="HMR / Module\nfetch & invoke" dir=both style=bold color="${#6f42c1|#c8abfa}"] } One of the goals of this feature is to provide a customizable API to process and run code. Users can create new environment factories using the exposed primitives. import { DevEnvironment, HotChannel } from 'vite' function createWorkerdDevEnvironment( name: string, config: ResolvedConfig, context: DevEnvironmentContext ) { const connection = /* ... */ const transport: HotChannel = { on: (listener) => { connection.on('message', listener) }, send: (data) => connection.send(data), } const workerdDevEnvironment = new DevEnvironment(name, config, { options: { resolve: { conditions: ['custom'] }, ...context.options, }, hot: true, transport, }) return workerdDevEnvironment } By default, HotChannel transports have server.fs restrictions applied, meaning only files within the allowed directories can be served. If your transport is not exposed over the network (e.g., it communicates via worker threads or in-process calls), you can set skipFsCheck: true on the HotChannel to bypass these restrictions. There are multiple communication levels for the DevEnvironment. To make it easier for frameworks to write runtime agnostic code, we recommend to implement the most flexible communication level possible. ModuleRunner A module runner is instantiated in the target runtime. All APIs in the next section are imported from vite/module-runner unless stated otherwise. This export entry point is kept as lightweight as possible, only exporting the minimal needed to create module runners. Type Signature: export class ModuleRunner { constructor( public options: ModuleRunnerOptions, public evaluator: ModuleEvaluator = new ESModulesEvaluator(), private debug?: ModuleRunnerDebugger, ) {} /** * URL to execute. * Accepts file path, server path, or id relative to the root. */ public async import(url: string): Promise /** * Clear all caches including HMR listeners. */ public clearCache(): void /** * Clear all caches, remove all HMR listeners, reset sourcemap support. * This method doesn't stop the HMR connection. */ public async close(): Promise /** * Returns `true` if the runner has been closed by calling `close()`. */ public isClosed(): boolean } The module evaluator in ModuleRunner is responsible for executing the code. Vite exports ESModulesEvaluator out of the box, it uses new AsyncFunction to evaluate the code. You can provide your own implementation if your JavaScript runtime doesn't support unsafe evaluation. Module runner exposes import method. When Vite server triggers full-reload HMR event, all affected modules will be re-executed. Be aware that Module Runner doesn't update exports object when this happens (it overrides it), you would need to run import or get the module from evaluatedModules again if you rely on having the latest exports object. Example Usage: import { ModuleRunner, ESModulesEvaluator, createNodeImportMeta, } from 'vite/module-runner' import { transport } from './rpc-implementation.js' const moduleRunner = new ModuleRunner( { transport, createImportMeta: createNodeImportMeta, // if the module runner runs in Node.js }, new ESModulesEvaluator(), ) await moduleRunner.import('/src/entry-point.js') ModuleRunnerOptions import type { Debug } from '@type-challenges/utils' import type { InterceptorOptions as InterceptorOptionsRaw, ModuleRunnerHmr as ModuleRunnerHmrRaw, EvaluatedModules, } from 'vite/module-runner' type InterceptorOptions = Debug type ModuleRunnerHmr = Debug /** see below */ type ModuleRunnerTransport = unknown // ---cut--- interface ModuleRunnerOptions { /** * A set of methods to communicate with the server. */ transport: ModuleRunnerTransport /** * Configure how source maps are resolved. * Prefers `node` if `process.setSourceMapsEnabled` is available. * Otherwise it will use `prepareStackTrace` by default which overrides * `Error.prepareStackTrace` method. * You can provide an object to configure how file contents and * source maps are resolved for files that were not processed by Vite. */ sourcemapInterceptor?: | false | 'node' | 'prepareStackTrace' | InterceptorOptions /** * Disable HMR or configure HMR options. * * @default true */ hmr?: boolean | ModuleRunnerHmr /** * Custom module cache. If not provided, it creates a separate module * cache for each module runner instance. */ evaluatedModules?: EvaluatedModules } ModuleEvaluator Type Signature: import type { Debug } from '@type-challenges/utils' import type { ModuleRunnerContext as ModuleRunnerContextRaw } from 'vite/module-runner' type ModuleRunnerContext = Debug // ---cut--- export interface ModuleEvaluator { /** * Number of prefixed lines in the transformed code. */ startOffset?: number /** * Evaluate code that was transformed by Vite. * @param context Function context * @param code Transformed code * @param id ID that was used to fetch the module */ runInlinedModule( context: ModuleRunnerContext, code: string, id: string, ): Promise /** * evaluate externalized module. * @param file File URL to the external module */ runExternalModule(file: string): Promise } Vite exports ESModulesEvaluator that implements this interface by default. It uses new AsyncFunction to evaluate code, so if the code has inlined source map it should contain an offset of 2 lines to accommodate for new lines added. This is done automatically by the ESModulesEvaluator. Custom evaluators will not add additional lines. ModuleRunnerTransport Type Signature: import type { ModuleRunnerTransportHandlers } from 'vite/module-runner' /** an object */ type HotPayload = unknown // ---cut--- interface ModuleRunnerTransport { connect?(handlers: ModuleRunnerTransportHandlers): Promise | void disconnect?(): Promise | void send?(data: HotPayload): Promise | void invoke?(data: HotPayload): Promise<{ result: any } | { error: any }> timeout?: number } Transport object that communicates with the environment via an RPC or by directly calling the function. When invoke method is not implemented, the send method and connect method is required to be implemented. Vite will construct the invoke internally. You need to couple it with the HotChannel instance on the server like in this example where module runner is created in the worker thread: ::: code-group import { fileURLToPath } from 'node:url' import { parentPort } from 'node:worker_threads' import { ESModulesEvaluator, ModuleRunner, createNodeImportMeta, } from 'vite/module-runner' /** @type {import('vite/module-runner').ModuleRunnerTransport} */ const transport = { connect({ onMessage, onDisconnection }) { parentPort.on('message', onMessage) parentPort.on('close', onDisconnection) }, send(data) { parentPort.postMessage(data) }, } const runner = new ModuleRunner( { transport, createImportMeta: createNodeImportMeta, }, new ESModulesEvaluator(), ) import { BroadcastChannel } from 'node:worker_threads' import { createServer, DevEnvironment } from 'vite' function createWorkerEnvironment(name, config, context) { const worker = new Worker('./worker.js') const handlerToWorkerListener = new WeakMap() const client = { send(payload: HotPayload) { worker.postMessage(payload) }, } const workerHotChannel = { // Worker threads post messages are not exposed over the network, skip server.fs checks skipFsCheck: true, send: (data) => worker.postMessage(data), on: (event, handler) => { // client is already connected if (event === 'vite:client:connect') return if (event === 'vite:client:disconnect') { const listener = () => { handler(undefined, client) } handlerToWorkerListener.set(handler, listener) worker.on('exit', listener) return } const listener = (value) => { if (value.type === 'custom' && value.event === event) { handler(value.data, client) } } handlerToWorkerListener.set(handler, listener) worker.on('message', listener) }, off: (event, handler) => { if (event === 'vite:client:connect') return if (event === 'vite:client:disconnect') { const listener = handlerToWorkerListener.get(handler) if (listener) { worker.off('exit', listener) handlerToWorkerListener.delete(handler) } return } const listener = handlerToWorkerListener.get(handler) if (listener) { worker.off('message', listener) handlerToWorkerListener.delete(handler) } }, } return new DevEnvironment(name, config, { transport: workerHotChannel, }) } await createServer({ environments: { worker: { dev: { createEnvironment: createWorkerEnvironment, }, }, }, }) ::: Make sure to implement the vite:client:connect / vite:client:disconnect events in the on / off methods when those methods exist. vite:client:connect event should be emitted when the connection is established, and vite:client:disconnect event should be emitted when the connection is closed. The HotChannelClient object passed to the event handler must have the same reference for the same connection. A different example using an HTTP request to communicate between the runner and the server: import { ESModulesEvaluator, ModuleRunner } from 'vite/module-runner' export const runner = new ModuleRunner( { transport: { async invoke(data) { const response = await fetch(`http://my-vite-server/invoke`, { method: 'POST', body: JSON.stringify(data), }) return response.json() }, }, hmr: false, // disable HMR as HMR requires transport.connect }, new ESModulesEvaluator(), ) await runner.import('/entry.js') In this case, the handleInvoke method in the NormalizedHotChannel can be used: const customEnvironment = new DevEnvironment(name, config, context) server.onRequest((request: Request) => { const url = new URL(request.url) if (url.pathname === '/invoke') { const payload = (await request.json()) as HotPayload const result = customEnvironment.hot.handleInvoke(payload) return new Response(JSON.stringify(result)) } return Response.error() }) But note that for HMR support, send and connect methods are required. The send method is usually called when the custom event is triggered (like, import.meta.hot.send("my-event")). For an SSR environment running in the same Node.js process as the Vite server, Vite exports createServerHotChannel as a ready-made HotChannel: import { createServerHotChannel, DevEnvironment } from 'vite' new DevEnvironment(name, config, { hot: true, transport: createServerHotChannel(), }) Environment API :::info Release Candidate The Environment API is generally in the release candidate phase. We'll maintain stability in the APIs between major releases to allow the ecosystem to experiment and build upon them. However, note that some specific APIs are still considered experimental. We plan to stabilize these new APIs (with potential breaking changes) in a future major release once downstream projects have had time to experiment with the new features and validate them. Resources: Feedback discussion where we are gathering feedback about the new APIs. Environment API PR where the new APIs were implemented and reviewed. Please share your feedback with us. ::: Formalizing Environments Vite 6 formalizes the concept of Environments. Until Vite 5, there were two implicit Environments ( client, and optionally ssr). The new Environment API allows users and framework authors to create as many environments as needed to map the way their apps work in production. This new capability required a big internal refactoring, but a lot of effort has been placed on backward compatibility. The initial goal of Vite 6 is to move the ecosystem to the new major as smoothly as possible, delaying the adoption of the APIs until enough users have migrated and frameworks and plugin authors have validated the new design. Closing the Gap Between Build and Dev For a simple SPA/MPA, no new APIs around environments are exposed to the config. Internally, Vite will apply the options to a client environment, but it's not necessary to know of this concept when configuring Vite. The config and behavior from Vite 5 should work seamlessly here. When we move to a typical server-side rendered (SSR) app, we'll have two environments: client: runs the app in the browser. ssr: runs the app in node (or other server runtimes) which renders pages before sending them to the browser. In dev, Vite executes the server code in the same Node process as the Vite dev server, giving a close approximation to the production environment. However, it is also possible for servers to run in other JS runtimes, like Cloudflare's workerd which have different constraints. Modern apps may also run in more than two environments, e.g. a browser, a node server, and an edge server. Vite 5 didn't allow these environments to be properly represented. Vite 6 allows users to configure their app during build and dev to map all of its environments. During dev, a single Vite dev server can now be used to run code in multiple different environments concurrently. The app source code is still transformed by Vite dev server. On top of the shared HTTP server, middlewares, resolved config, and plugins pipeline, the Vite dev server now has a set of independent dev environments. Each of them is configured to match the production environment as closely as possible, and is connected to a dev runtime where the code is executed (for workerd, the server code can now run in miniflare locally). In the client, the browser imports and executes the code. In other environments, a module runner fetches and evaluates the transformed code. Environments Configuration For an SPA/MPA, the configuration will look similar to Vite 5. Internally these options are used to configure the client environment. export default defineConfig({ build: { sourcemap: false, }, optimizeDeps: { include: ['lib'], }, }) This is important because we'd like to keep Vite approachable and avoid exposing new concepts until they are needed. If the app is composed of several environments, then these environments can be configured explicitly with the environments config option. export default { build: { sourcemap: false, }, optimizeDeps: { include: ['lib'], }, environments: { server: {}, edge: { resolve: { noExternal: true, }, }, }, } When not explicitly documented, environment inherits the configured top-level config options (for example, the new server and edge environments will inherit the build.sourcemap: false option). A small number of top-level options, like optimizeDeps, only apply to the client environment, as they don't work well when applied as a default to server environments. Those options have badge in the reference. The client environment can also be configured explicitly through environments.client, but we recommend to do it with the top-level options so the client config remains unchanged when adding new environments. The EnvironmentOptions interface exposes all the per-environment options. There are environment options that apply to both build and dev, like resolve. And there are DevEnvironmentOptions and BuildEnvironmentOptions for dev and build specific options (like dev.warmup or build.outDir). Some options like optimizeDeps only applies to dev, but is kept as top level instead of nested in dev for backward compatibility. interface EnvironmentOptions { define?: Record resolve?: EnvironmentResolveOptions optimizeDeps: DepOptimizationOptions consumer?: 'client' | 'server' dev: DevOptions build: BuildOptions } The UserConfig interface extends from the EnvironmentOptions interface, allowing to configure the client and defaults for other environments, configured through the environments option. The client and a server environment named ssr are always present during dev. This allows backward compatibility with server.ssrLoadModule(url) and server.moduleGraph. During build, the client environment is always present, and the ssr environment is only present if it is explicitly configured (using environments.ssr or for backward compatibility build.ssr). An app doesn't need to use the ssr name for its SSR environment, it could name it server for example. interface UserConfig extends EnvironmentOptions { environments: Record // other options } Note that the ssr top-level property is going to be deprecated once the Environment API is stable. This option has the same role as environments, but for the default ssr environment and only allowed configuring of a small set of options. Custom Environment Instances Low level configuration APIs are available so runtime providers can provide environments with proper defaults for their runtimes. These environments can also spawn other processes or threads to run the modules during dev in a closer runtime to the production environment. For example, the Cloudflare Vite plugin uses the Environment API to run code in the Cloudflare Workers runtime ( workerd) during development. import { customEnvironment } from 'vite-environment-provider' export default { build: { outDir: '/dist/client', }, environments: { ssr: customEnvironment({ build: { outDir: '/dist/ssr', }, }), }, } Backward Compatibility The current Vite server API is not yet deprecated and is backward compatible with Vite 5. The server.moduleGraph returns a mixed view of the client and ssr module graphs. Backward compatible mixed module nodes will be returned from all its methods. The same scheme is used for the module nodes passed to handleHotUpdate. We don't recommend switching to Environment API yet. We are aiming for a good portion of the user base to adopt Vite 6 before so plugins don't need to maintain two versions. Checkout the future breaking changes section for information on future deprecations and upgrade path: this.environment in Hooks HMR hotUpdate Plugin Hook Move to Per-environment APIs SSR Using ModuleRunner API Shared Plugins During Build Target Users This guide provides the basic concepts about environments for end users. Plugin authors have a more consistent API available to interact with the current environment configuration. If you're building on top of Vite, the Environment API Plugins Guide guide describes the way extended plugin APIs available to support multiple custom environments. Frameworks could decide to expose environments at different levels. If you're a framework author, continue reading the Environment API Frameworks Guide to learn about the Environment API programmatic side. For Runtime providers, the Environment API Runtimes Guide explains how to offer custom environments to be consumed by frameworks and users. HMR API :::tip Note This is the client HMR API. For handling HMR update in plugins, see handleHotUpdate. The manual HMR API is primarily intended for framework and tooling authors. As an end user, HMR is likely already handled for you in the framework specific starter templates. ::: Vite exposes its manual HMR API via the special import.meta.hot object: import type { CustomEventName, InferCustomEventPayload, } from 'vite/types/customEvent.d.ts' import type { ModuleNamespace } from 'vite/types/hot.d.ts' // ---cut--- interface ImportMeta { readonly hot?: ViteHotContext } interface ViteHotContext { readonly data: any accept(): void accept(cb: (mod: ModuleNamespace | undefined) => void): void accept(dep: string, cb: (mod: ModuleNamespace | undefined) => void): void accept( deps: readonly string[], cb: (mods: Array) => void, ): void dispose(cb: (data: any) => void): void prune(cb: (data: any) => void): void invalidate(message?: string): void on( event: T, cb: (payload: InferCustomEventPayload) => void, ): void off( event: T, cb: (payload: InferCustomEventPayload) => void, ): void send( event: T, data?: InferCustomEventPayload, ): void } Required Conditional Guard First of all, make sure to guard all HMR API usage with a conditional block so that the code can be tree-shaken in production: if (import.meta.hot) { // HMR code } IntelliSense for TypeScript Vite provides type definitions for import.meta.hot in vite/client.d.ts. You can add "vite/client" in the tsconfig.json so TypeScript picks up the type definitions: { "compilerOptions": { "types": ["vite/client"] } } hot.accept(cb) For a module to self-accept, use import.meta.hot.accept with a callback which receives the updated module: import 'vite/client' // ---cut--- export const count = 1 if (import.meta.hot) { import.meta.hot.accept((newModule) => { if (newModule) { // newModule is undefined when SyntaxError happened console.log('updated: count is now ', newModule.count) } }) } A module that "accepts" hot updates is considered an HMR boundary. digraph hmr_boundary { rankdir=RL ranksep=0.3 node [shape=box style="rounded,filled" fontname="Arial" fontsize=11 margin="0.2,0.1" fontcolor="${#3c3c43|#ffffff}" color="${#c2c2c4|#3c3f44}"] edge [color="${#67676c|#98989f}" fontname="Arial" fontsize=10 fontcolor="${#67676c|#98989f}"] bgcolor="transparent" root [label="main.js" fillcolor="${#f6f6f7|#2e2e32}"] parent [label="App.vue" fillcolor="${#f6f6f7|#2e2e32}"] boundary [label="Component.vue\n(HMR boundary)\nhot.accept()" fillcolor="${#def5ed|#15312d}" color="${#18794e|#3dd68c}" penwidth=2] edited [label="utils.js\n(edited)" fillcolor="${#fcf4dc|#38301a}" color="${#915930|#f9b44e}" penwidth=2] boundary -> edited [label="imports" color="${#915930|#f9b44e}" style=bold] parent -> boundary [label="imports" style=dashed] root -> parent [label="imports" style=dashed] } Vite's HMR does not actually swap the originally imported module: if an HMR boundary module re-exports imports from a dep, then it is responsible for updating those re-exports (and these exports must be using let). In addition, importers up the chain from the boundary module will not be notified of the change. This simplified HMR implementation is sufficient for most dev use cases, while allowing us to skip the expensive work of generating proxy modules. Vite requires that the call to this function appears as import.meta.hot.accept( (whitespace-sensitive) in the source code in order for the module to accept update. This is a requirement of the static analysis that Vite does to enable HMR support for a module. hot.accept(deps, cb) A module can also accept updates from direct dependencies without reloading itself: // @filename: /foo.d.ts export declare const foo: () => void // @filename: /example.js import 'vite/client' // ---cut--- import { foo } from './foo.js' foo() if (import.meta.hot) { import.meta.hot.accept('./foo.js', (newFoo) => { // the callback receives the updated './foo.js' module newFoo?.foo() }) // Can also accept an array of dep modules: import.meta.hot.accept( ['./foo.js', './bar.js'], ([newFooModule, newBarModule]) => { // The callback receives an array where only the updated module is // non null. If the update was not successful (syntax error for ex.), // the array is empty }, ) } hot.dispose(cb) A self-accepting module or a module that expects to be accepted by others can use hot.dispose to clean-up any persistent side effects created by its updated copy: import 'vite/client' // ---cut--- function setupSideEffect() {} setupSideEffect() if (import.meta.hot) { import.meta.hot.dispose((data) => { // cleanup side effect }) } hot.prune(cb) Register a callback that will be called when the module is no longer imported on the page. Compared to hot.dispose, this can be used if the source code cleans up side-effects by itself on updates and you only need to clean-up when it's removed from the page. Vite currently uses this for .css imports. import 'vite/client' // ---cut--- function setupOrReuseSideEffect() {} setupOrReuseSideEffect() if (import.meta.hot) { import.meta.hot.prune((data) => { // cleanup side effect }) } hot.data Vite creates one import.meta.hot.data object for each module path. The object is persisted across successive instances of the same module during HMR. Mutations made during module execution or through the data argument passed to hot.dispose are visible to the next instance of the module. When a module is pruned, its hot.dispose and hot.prune callbacks receive the current data object. Vite clears the data after those callbacks complete. If the module is imported again later, it receives a new empty data object. Note that re-assignment of data itself is not supported. Instead, you should mutate properties of the data object so information added from other handlers are preserved. import 'vite/client' // ---cut--- // ok import.meta.hot.data.someValue = 'hello' // not supported import.meta.hot.data = { someValue: 'hello' } hot.decline() This is currently a noop and is there for backward compatibility. This could change in the future if there is a new usage for it. To indicate that the module is not hot-updatable, use hot.invalidate(). hot.invalidate(message?: string) A self-accepting module may realize during runtime that it can't handle a HMR update, and so the update needs to be forcefully propagated to importers. By calling import.meta.hot.invalidate(), the HMR server will invalidate the importers of the caller, as if the caller wasn't self-accepting. This will log a message both in the browser console and in the terminal. You can pass a message to give some context on why the invalidation happened. Note that you should always call import.meta.hot.accept even if you plan to call invalidate immediately afterwards, or else the HMR client won't listen for future changes to the self-accepting module. To communicate your intent clearly, we recommend calling invalidate within the accept callback like so: import 'vite/client' // ---cut--- import.meta.hot.accept((module) => { // You may use the new module instance to decide whether to invalidate. if (cannotHandleUpdate(module)) { import.meta.hot.invalidate() } }) hot.on(event, cb) Listen to an HMR event. The following HMR events are dispatched by Vite automatically: 'vite:beforeUpdate' when an update is about to be applied (e.g. a module will be replaced) 'vite:afterUpdate' when an update has just been applied (e.g. a module has been replaced) 'vite:beforeFullReload' when a full reload is about to occur 'vite:beforePrune' when modules that are no longer needed are about to be pruned 'vite:invalidate' when a module is invalidated with import.meta.hot.invalidate() 'vite:error' when an error occurs (e.g. syntax error) 'vite:ws:disconnect' when the WebSocket connection is lost 'vite:ws:connect' when the WebSocket connection is (re-)established Custom HMR events can also be sent from plugins. See handleHotUpdate for more details. hot.off(event, cb) Remove callback from the event listeners. hot.send(event, data) Send custom events back to Vite's dev server. If called before connected, the data will be buffered and sent once the connection is established. See Client-server Communication for more details, including a section on Typing Custom Events. Further Reading If you'd like to learn more about how to use the HMR API and how it works under-the-hood. Check out these resources: Hot Module Replacement is Easy JavaScript API Vite's JavaScript APIs are fully typed, and it's recommended to use TypeScript or enable JS type checking in VS Code to leverage the IntelliSense and validation. createServer Type Signature: async function createServer(inlineConfig?: InlineConfig): Promise Example Usage: import { createServer } from 'vite' const server = await createServer({ // any valid user config options, plus `mode` and `configFile` configFile: false, root: import.meta.dirname, server: { port: 1337, }, }) await server.listen() server.printUrls() server.bindCLIShortcuts({ print: true }) ::: tip NOTE When using createServer and build in the same Node.js process, both functions rely on process.env.NODE_ENV to work properly, which also depends on the mode config option. To prevent conflicting behavior, set process.env.NODE_ENV or the mode of the two APIs to development. Otherwise, you can spawn a child process to run the APIs separately. ::: ::: tip NOTE When using middleware mode combined with proxy config for WebSocket, the parent http server should be provided in middlewareMode to bind the proxy correctly. Example import http from 'http' import { createServer } from 'vite' const parentServer = http.createServer() // or express, koa, etc. const vite = await createServer({ server: { // Enable middleware mode middlewareMode: { // Provide the parent http server for proxy WebSocket server: parentServer, }, proxy: { '/ws': { target: 'ws://localhost:3000', // Proxying WebSocket ws: true, }, }, }, }) // @noErrors: 2339 parentServer.use(vite.middlewares) ::: InlineConfig The InlineConfig interface extends UserConfig with additional properties: configFile: specify config file to use. If not set, Vite will try to automatically resolve one from project root. Set to false to disable auto resolving. ResolvedConfig The ResolvedConfig interface has all the same properties of a UserConfig, except most properties are resolved and non-undefined. It also contains utilities like: config.assetsInclude: A function to check if an id is considered an asset. config.logger: Vite's internal logger object. ViteDevServer interface ViteDevServer { /** * The resolved Vite config object. */ config: ResolvedConfig /** * A connect app instance * - Can be used to attach custom middlewares to the dev server. * - Can also be used as the handler function of a custom http server * or as a middleware in any connect-style Node.js frameworks. * * https://github.com/senchalabs/connect#use-middleware */ middlewares: Connect.Server /** * Native Node http server instance. * Will be null in middleware mode. */ httpServer: http.Server | null /** * Chokidar watcher instance. If `config.server.watch` is set to `null`, * it will not watch any files and calling `add` or `unwatch` will have no effect. * https://github.com/paulmillr/chokidar/tree/3.6.0#api */ watcher: FSWatcher /** * WebSocket server with `send(payload)` method. */ ws: WebSocketServer /** * Rollup plugin container that can run plugin hooks on a given file. */ pluginContainer: PluginContainer /** * Module graph that tracks the import relationships, url to file mapping * and hmr state. */ moduleGraph: ModuleGraph /** * The resolved urls Vite prints on the CLI (URL-encoded). Returns `null` * in middleware mode or if the server is not listening on any port. */ resolvedUrls: ResolvedServerUrls | null /** * Programmatically resolve, load and transform a URL and get the result * without going through the http request pipeline. */ transformRequest( url: string, options?: TransformOptions, ): Promise /** * Apply Vite built-in HTML transforms and any plugin HTML transforms. */ transformIndexHtml( url: string, html: string, originalUrl?: string, ): Promise /** * Load a given URL as an instantiated module for SSR. */ ssrLoadModule( url: string, options?: { fixStacktrace?: boolean }, ): Promise> /** * Fix ssr error stacktrace. */ ssrFixStacktrace(e: Error): void /** * Triggers HMR for a module in the module graph. You can use the `server.moduleGraph` * API to retrieve the module to be reloaded. If `hmr` is false, this is a no-op. */ reloadModule(module: ModuleNode): Promise /** * Start the server. */ listen(port?: number, isRestart?: boolean): Promise /** * Restart the server. * * @param forceOptimize - force the optimizer to re-bundle, same as --force cli flag */ restart(forceOptimize?: boolean): Promise /** * Stop the server. */ close(): Promise /** * Bind CLI shortcuts */ bindCLIShortcuts(options?: BindCLIShortcutsOptions): void /** * Calling `await server.waitForRequestsIdle(id)` will wait until all static imports * are processed. If called from a load or transform plugin hook, the id needs to be * passed as a parameter to avoid deadlocks. Calling this function after the first * static imports section of the module graph has been processed will resolve immediately. * @experimental */ waitForRequestsIdle: (ignoredId?: string) => Promise } :::info waitForRequestsIdle is meant to be used as an escape hatch to improve DX for features that can't be implemented following the on-demand nature of the Vite dev server. It can be used during startup by tools like Tailwind to delay generating the app CSS classes until the app code has been seen, avoiding flashes of style changes. When this function is used in a load or transform hook, and the default HTTP1 server is used, one of the six http channels will be blocked until the server processes all static imports. Vite's dependency optimizer currently uses this function to avoid full-page reloads on missing dependencies by delaying loading of pre-bundled dependencies until all imported dependencies have been collected from static imported sources. Vite may switch to a different strategy in a future major release, setting optimizeDeps.holdUntilCrawlEnd: false by default to avoid the performance hit in large applications during cold start. ::: build Type Signature: async function build( inlineConfig?: InlineConfig, ): Promise Example Usage: import path from 'node:path' import { build } from 'vite' await build({ root: path.resolve(import.meta.dirname, './project'), base: '/foo/', build: { rolldownOptions: { // ... }, }, }) preview Type Signature: async function preview(inlineConfig?: InlineConfig): Promise Example Usage: import { preview } from 'vite' const previewServer = await preview({ // any valid user config options, plus `mode` and `configFile` preview: { port: 8080, open: true, }, }) previewServer.printUrls() previewServer.bindCLIShortcuts({ print: true }) PreviewServer interface PreviewServer { /** * The resolved vite config object */ config: ResolvedConfig /** * A connect app instance. * - Can be used to attach custom middlewares to the preview server. * - Can also be used as the handler function of a custom http server * or as a middleware in any connect-style Node.js frameworks * * https://github.com/senchalabs/connect#use-middleware */ middlewares: Connect.Server /** * native Node http server instance */ httpServer: http.Server /** * The resolved urls Vite prints on the CLI (URL-encoded). Returns `null` * if the server is not listening on any port. */ resolvedUrls: ResolvedServerUrls | null /** * Print server urls */ printUrls(): void /** * Bind CLI shortcuts */ bindCLIShortcuts(options?: BindCLIShortcutsOptions): void } resolveConfig Type Signature: async function resolveConfig( inlineConfig: InlineConfig, command: 'build' | 'serve', defaultMode = 'development', defaultNodeEnv = 'development', isPreview = false, ): Promise The command value is serve in dev and preview, and build in build. mergeConfig Type Signature: function mergeConfig( defaults: Record, overrides: Record, isRoot = true, ): Record Deeply merge two Vite configs. isRoot represents the level within the Vite config which is being merged. For example, set false if you're merging two build options. Note that null and undefined values in overrides are skipped and not merged. If you need to explicitly clear a value from defaults, modify the result of mergeConfig directly. ::: tip NOTE mergeConfig accepts only config in object form. If you have a config in callback form, you should call it before passing into mergeConfig. You can use the defineConfig helper to merge a config in callback form with another config: import { defineConfig, mergeConfig, type UserConfigFnObject, type UserConfig, } from 'vite' declare const configAsCallback: UserConfigFnObject declare const configAsObject: UserConfig // ---cut--- export default defineConfig((configEnv) => mergeConfig(configAsCallback(configEnv), configAsObject), ) ::: searchForWorkspaceRoot Type Signature: function searchForWorkspaceRoot( current: string, root = searchForPackageRoot(current), ): string Related: server.fs.allow Search for the root of the potential workspace if it meets the following conditions, otherwise it would fallback to root: contains workspaces field in package.json contains one of the following file lerna.json pnpm-workspace.yaml loadEnv Type Signature: function loadEnv( mode: string, envDir: string, prefixes: string | string[] = 'VITE_', ): Record Related: .env Files Load .env files within the envDir and merge them with the matching variables already present in process.env. By default, only env variables prefixed with VITE_ are loaded, unless prefixes is changed. normalizePath Type Signature: function normalizePath(id: string): string Related: Path Normalization Normalizes a path to interoperate between Vite plugins. transformWithOxc Type Signature: async function transformWithOxc( code: string, filename: string, options?: OxcTransformOptions, inMap?: object, ): Promise & { warnings: string[] }> Transform JavaScript or TypeScript with Oxc Transformer. Useful for plugins that prefer matching Vite's internal Oxc Transformer transform. transformWithEsbuild Type Signature: async function transformWithEsbuild( code: string, filename: string, options?: EsbuildTransformOptions, inMap?: object, ): Promise Deprecated: Use transformWithOxc instead. Transform JavaScript or TypeScript with esbuild. Useful for plugins that prefer matching Vite's internal esbuild transform. loadConfigFromFile Type Signature: async function loadConfigFromFile( configEnv: ConfigEnv, configFile?: string, configRoot: string = process.cwd(), logLevel?: LogLevel, customLogger?: Logger, ): Promise<{ path: string config: UserConfig dependencies: string[] } | null> Load a Vite config file manually with Rolldown. preprocessCSS Experimental: Give Feedback Type Signature: async function preprocessCSS( code: string, filename: string, config: ResolvedConfig, ): Promise interface PreprocessCSSResult { code: string map?: SourceMapInput modules?: Record deps?: Set } Pre-processes .css, .scss, .sass, .less, .styl and .stylus files to plain CSS so it can be used in browsers or parsed by other tools. Similar to the built-in CSS pre-processing support, the corresponding pre-processor must be installed if used. The pre-processor used is inferred from the filename extension. If the filename ends with .module.{ext}, it is inferred as a CSS module and the returned result will include a modules object mapping the original class names to the transformed ones. Note that pre-processing will not resolve URLs in url() or image-set(). version Type: string The current version of Vite as a string (e.g. "8.0.0"). rolldownVersion Type: string The version of Rolldown used by Vite as a string (e.g. "1.0.0"). A re-export of VERSION from rolldown. esbuildVersion Type: string Only kept for backward compatibility. rollupVersion Type: string Only kept for backward compatibility. Plugin API Vite plugins extends Rolldown's plugin interface with a few extra Vite-specific options. As a result, you can write a Vite plugin once and have it work for both dev and build. It is recommended to go through Rolldown's plugin documentation first before reading the sections below. Authoring a Plugin Vite strives to offer established patterns out of the box, so before creating a new plugin make sure that you check the Features guide to see if your need is covered. Also review available community plugins, both in the form of a compatible Rollup plugin and Vite Specific plugins. When creating a plugin, you can inline it in your vite.config.js. There is no need to create a new package for it. Once you see that a plugin was useful in your projects, consider sharing it to help others in the ecosystem. ::: tip When learning, debugging, or authoring plugins, we suggest including vite-plugin-inspect in your project. It allows you to inspect the intermediate state of Vite plugins. After installing, you can visit localhost:5173/__inspect/ to inspect the modules and transformation stack of your project. Check out install instructions in the vite-plugin-inspect docs. ::: Conventions If the plugin doesn't use Vite specific hooks and can be implemented as a Compatible Rolldown Plugin, then it is recommended to use the Rolldown Plugin naming conventions. Rolldown Plugins should have a clear name with rolldown-plugin- prefix. Include rolldown-plugin and vite-plugin keywords in package.json keywords field. This exposes the plugin to be also used in pure Rolldown or Rollup based projects. For Vite only plugins Vite Plugins should have a clear name with vite-plugin- prefix. Include vite-plugin keyword in package.json keywords field. Include a section in the plugin docs detailing why it is a Vite only plugin (for example, it uses Vite specific plugin hooks). If your plugin is only going to work for a particular framework, its name should be included as part of the prefix. vite-plugin-vue- prefix for Vue Plugins vite-plugin-react- prefix for React Plugins vite-plugin-svelte- prefix for Svelte Plugins See also Virtual Modules Convention. Plugins Config Users will add plugins to the project devDependencies and configure them using the plugins array option. import rollupPlugin from 'rollup-plugin-feature' import vitePlugin from 'vite-plugin-feature' export default defineConfig({ plugins: [vitePlugin(), rollupPlugin()], }) Falsy plugins will be ignored, which can be used to easily activate or deactivate plugins. plugins also accepts presets including several plugins as a single element. This is useful for complex features (like framework integration) that are implemented using several plugins. The array will be flattened internally. import frameworkDevTools from 'vite-plugin-framework-devtools' import frameworkRefresh from 'vite-plugin-framework-refresh' export default function framework(config) { return [frameworkRefresh(config), frameworkDevTools(config)] } import { defineConfig } from 'vite' import framework from 'vite-plugin-framework' export default defineConfig({ plugins: [framework()], }) Simple Examples :::tip It is common convention to author a Vite/Rolldown/Rollup plugin as a factory function that returns the actual plugin object. The function can accept options which allows users to customize the behavior of the plugin. ::: Transforming Custom File Types const fileRegex = /\.(my-file-ext)$/ export default function myPlugin() { return { name: 'transform-file', transform: { filter: { id: fileRegex, }, handler(src, id) { return { code: compileFileToJS(src), map: null, // provide source map if available } }, }, } } Importing a Virtual File Virtual modules allow you to pass build time information to the source files using normal ESM import syntax. See Virtual Modules Convention for the full convention. import { exactRegex } from '@rolldown/pluginutils' export default function myPlugin() { const virtualModuleId = 'virtual:my-module' const resolvedVirtualModuleId = '\0' + virtualModuleId return { name: 'my-plugin', // required, will show up in warnings and errors resolveId: { filter: { id: exactRegex(virtualModuleId) }, handler() { return resolvedVirtualModuleId }, }, load: { filter: { id: exactRegex(resolvedVirtualModuleId) }, handler() { return `export const msg = "from virtual module"` }, }, } } Which allows importing the module in JavaScript: import { msg } from 'virtual:my-module' console.log(msg) In Vite, since \0 is not a permitted char in import URLs, a \0{id} virtual id ends up encoded as /@id/__x00__{id} during dev in the browser. The id is decoded back before entering the plugins pipeline, so this is not seen by plugin hooks code. Rolldown Hooks During dev, the Vite dev server creates a plugin container that invokes Rolldown Build Hooks the same way Rolldown does it. All rolldown hooks are per-environment hooks. The following hooks are called once on server start: options buildStart The following hooks are called on each incoming module request: resolveId load transform These hooks also have an extended options parameter with additional Vite-specific properties. You can read more in the SSR documentation. Some resolveId calls' importer value may be an absolute path for a generic index.html at root as it's not always possible to derive the actual importer due to Vite's unbundled dev server pattern. For imports handled within Vite's resolve pipeline, the importer can be tracked during the import analysis phase, providing the correct importer value. The following hooks are called when the server is closed: buildEnd closeBundle Note that the moduleParsed hook is not called during dev, because Vite avoids full AST parses for better performance. Output Generation Hooks (except closeBundle) are not called during dev. Vite Specific Hooks Vite plugins can also provide hooks that serve Vite-specific purposes. These hooks are ignored by Rollup. config Type: (config: UserConfig, env: { mode: 'build' | 'serve', command: string, isSsrBuild?: boolean, isPreview?: boolean }) => UserConfig | null | void Kind: async, sequential Scope: Global Modify Vite config before it's resolved. The hook receives the raw user config (CLI options merged with config file) and the current config env which exposes the mode and command being used. It can return a partial config object that will be deeply merged into existing config, or directly mutate the config (if the default merging cannot achieve the desired result). Example: // return partial config (recommended) const partialConfigPlugin = () => ({ name: 'return-partial', config: () => ({ resolve: { alias: { foo: 'bar', }, }, }), }) // mutate the config directly (use only when merging doesn't work) const mutateConfigPlugin = () => ({ name: 'mutate-config', config(config, { command }) { if (command === 'build') { config.root = 'foo' } }, }) ::: warning Note User plugins are resolved before running this hook so injecting other plugins inside the config hook will have no effect. ::: configResolved Type: (config: ResolvedConfig) => void | Promise Kind: async, parallel Scope: Global Called after the Vite config is resolved. Use this hook to read and store the final resolved config. It is also useful when the plugin needs to do something different based on the command being run. Example: const examplePlugin = () => { let config return { name: 'read-config', configResolved(resolvedConfig) { // store the resolved config config = resolvedConfig }, // use stored config in other hooks transform(code, id) { if (config.command === 'serve') { // dev: plugin invoked by dev server } else { // build: plugin invoked by Rollup } }, } } Note that the command value is serve in dev (in the cli vite, vite dev, and vite serve are aliases). configureServer Type: (server: ViteDevServer) => (() => void) | void | Promise<(() => void) | void> Kind: async, sequential See also: ViteDevServer Scope: Global Hook for configuring the dev server. The most common use case is adding custom middlewares to the internal connect app: const myPlugin = () => ({ name: 'configure-server', configureServer(server) { server.middlewares.use((req, res, next) => { // custom handle request... }) }, }) Injecting Post Middleware The configureServer hook is called before internal middlewares are installed, so the custom middlewares will run before internal middlewares by default. If you want to inject a middleware after internal middlewares, you can return a function from configureServer, which will be called after internal middlewares are installed: const myPlugin = () => ({ name: 'configure-server', configureServer(server) { // return a post hook that is called after internal middlewares are // installed return () => { server.middlewares.use((req, res, next) => { // custom handle request... }) } }, }) Storing Server Access In some cases, other plugin hooks may need access to the dev server instance (e.g. accessing the WebSocket server, the file system watcher, or the module graph). This hook can also be used to store the server instance for access in other hooks: const myPlugin = () => { let server return { name: 'configure-server', configureServer(_server) { server = _server }, transform(code, id) { if (server) { // use server... } }, } } Note configureServer is not called when running the production build so your other hooks need to guard against its absence. configurePreviewServer Type: (server: PreviewServer) => (() => void) | void | Promise<(() => void) | void> Kind: async, sequential See also: PreviewServer Scope: Global Same as configureServer but for the preview server. Similarly to configureServer, the configurePreviewServer hook is called before other middlewares are installed. If you want to inject a middleware after other middlewares, you can return a function from configurePreviewServer, which will be called after internal middlewares are installed: const myPlugin = () => ({ name: 'configure-preview-server', configurePreviewServer(server) { // return a post hook that is called after other middlewares are // installed return () => { server.middlewares.use((req, res, next) => { // custom handle request... }) } }, }) closeServer Type: (context: { reason: 'restart' | 'close' }) => void | Promise Kind: async, parallel Scope: Global Called when the dev server is restarted or closed, after the server has been torn down. Typically used to dispose resources created in configureServer. The context.reason distinguishes the two cases: 'restart': the server is restarting (e.g. a config file change or a call to server.restart()). 'close': the server is shutting down (e.g. the q shortcut, or a call to server.close()). const myPlugin = () => { let resource return { name: 'close-server', configureServer(server) { resource = createResource() }, async closeServer({ reason }) { if (reason === 'close') { await resource.dispose() } }, } } closePreviewServer Type: () => void | Promise Kind: async, parallel Scope: Global Same as closeServer but for the preview server. The preview server never restarts, so there is no reason. const myPlugin = () => { let resource return { name: 'close-preview-server', configurePreviewServer(server) { resource = createResource() }, async closePreviewServer() { await resource.dispose() }, } } transformIndexHtml Type: IndexHtmlTransformHook | { order?: 'pre' | 'post', handler: IndexHtmlTransformHook } Kind: async, sequential Scope: Per-environment Dedicated hook for transforming HTML entry point files such as index.html. The hook receives the current HTML string and a transform context. The context exposes the ViteDevServer instance during dev, and exposes the Rollup output bundle during build. The hook can be async and can return one of the following: Transformed HTML string An array of tag descriptor objects ( { tag, attrs, children }) to inject to the existing HTML. Each tag can also specify where it should be injected to (default is prepending to ) An object containing both as { html, tags } By default order is undefined, with this hook applied after the HTML has been transformed. In order to inject a script that should go through the Vite plugins pipeline, order: 'pre' will apply the hook before processing the HTML. order: 'post' applies the hook after all hooks with order undefined are applied. Basic Example: const htmlPlugin = () => { return { name: 'html-transform', transformIndexHtml(html) { return html.replace( /(.*?)<\/title>/, `<title>Title replaced!`, ) }, } } Full Hook Signature: type IndexHtmlTransformHook = ( html: string, ctx: { path: string filename: string server?: ViteDevServer bundle?: import('rolldown').OutputBundle chunk?: import('rolldown').OutputChunk originalUrl?: string }, ) => | IndexHtmlTransformResult | void | Promise type IndexHtmlTransformResult = | string | HtmlTagDescriptor[] | { html: string tags: HtmlTagDescriptor[] } interface HtmlTagDescriptor { tag: string /** * attribute values will be escaped automatically if needed */ attrs?: Record children?: string | HtmlTagDescriptor[] /** * default: 'head-prepend' */ injectTo?: 'head' | 'body' | 'head-prepend' | 'body-prepend' } ::: warning Note This hook won't be called if you are using a framework that has custom handling of entry files (for example SvelteKit). ::: handleHotUpdate Type: (ctx: HmrContext) => Array | void | Promise | void> Kind: async, sequential See also: HMR API Scope: Per-environment Perform custom HMR update handling. The hook receives a context object with the following signature: interface HmrContext { file: string timestamp: number modules: Array read: () => string | Promise server: ViteDevServer } modules is an array of modules that are affected by the changed file. It's an array because a single file may map to multiple served modules (e.g. Vue SFCs). read is an async read function that returns the content of the file. This is provided because on some systems, the file change callback may fire too fast before the editor finishes updating the file and direct fs.readFile will return empty content. The read function passed in normalizes this behavior. The hook can choose to: Filter and narrow down the affected module list so that the HMR is more accurate. Return an empty array and perform a full reload: handleHotUpdate({ server, modules, timestamp }) { // Invalidate modules manually const invalidatedModules = new Set() for (const mod of modules) { server.moduleGraph.invalidateModule( mod, invalidatedModules, timestamp, true ) } server.ws.send({ type: 'full-reload' }) return [] } Return an empty array and perform complete custom HMR handling by sending custom events to the client: handleHotUpdate({ server }) { server.ws.send({ type: 'custom', event: 'special-update', data: {} }) return [] } Client code should register corresponding handler using the HMR API (this could be injected by the same plugin's transform hook): if (import.meta.hot) { import.meta.hot.on('special-update', (data) => { // perform custom update }) } Plugin Context Meta For plugin hooks that have access to the plugin context, Vite exposes additional properties on this.meta: this.meta.viteVersion: The current Vite version string (e.g. "8.0.0"). ::: tip Detecting Rolldown powered Vite this.meta.rolldownVersion is only available for Rolldown powered Vite (i.e. Vite 8+). You can use it to detect whether the current Vite instance is powered by Rolldown: function versionCheckPlugin(): Plugin { return { name: 'version-check', buildStart() { if (this.meta.rolldownVersion) { // only do something if running on a Rolldown powered Vite } else { // do something else if running on a Rollup powered Vite } }, } } ::: Output Bundle Metadata During build, Vite augments Rolldown's build output objects with a Vite-specific viteMetadata field. This is available through: RenderedChunk (for example in renderChunk and augmentChunkHash) OutputChunk and OutputAsset (for example in generateBundle and writeBundle) viteMetadata provides: viteMetadata.importedCss: Set viteMetadata.importedAssets: Set This is useful when writing plugins that need to inspect emitted CSS and static assets without relying on build.manifest. Example: function outputMetadataPlugin(): Plugin { return { name: 'output-metadata-plugin', enforce: 'post', generateBundle(_, bundle) { for (const output of Object.values(bundle)) { const css = output.viteMetadata?.importedCss const assets = output.viteMetadata?.importedAssets if (!css?.size && !assets?.size) continue console.log(output.fileName, { css: css ? [...css] : [], assets: assets ? [...assets] : [], }) } }, } } Referencing Emitted Assets To emit an asset from a plugin, call this.emitFile({ type: 'asset', ... }). It returns a referenceId that you can use to generate the asset's URL, since its final file name isn't known until the bundle is generated. In JavaScript Use import.meta.ROLLDOWN_FILE_URL_: const referenceId = this.emitFile({ type: 'asset', name: 'icon.png', source: fileContent, }) // it's a JavaScript expression, so append any query or hash with string concatenation return `export default import.meta.ROLLDOWN_FILE_URL_${referenceId} + '#frag'` In CSS or HTML import.meta.ROLLDOWN_FILE_URL_ only works in JavaScript expression position. In CSS or HTML, use the __VITE_ASSET____ token instead, appending any query or hash right after it: background: url(__VITE_ASSET____#frag); Plugin Ordering A Vite plugin can additionally specify an enforce property (similar to webpack loaders) to adjust its application order. The value of enforce can be either "pre" or "post". The resolved plugins will be in the following order: Alias User plugins with enforce: 'pre' Vite core plugins User plugins without enforce value Vite build plugins User plugins with enforce: 'post' Vite post build plugins (minify, manifest, reporting) Note that this is separate from hooks ordering, those are still separately subject to their order attribute as usual for Rolldown hooks. Conditional Application By default plugins are invoked for both serve and build. In cases where a plugin needs to be conditionally applied only during serve or build, use the apply property to only invoke them during 'build' or 'serve': function myPlugin() { return { name: 'build-only', apply: 'build', // or 'serve' } } A function can also be used for more precise control: apply(config, { command }) { // apply only on build but not for SSR return command === 'build' && !config.build.ssr } Rolldown Plugin Compatibility A fair number of Rolldown / Rollup plugins will work directly as a Vite plugin (e.g. @rollup/plugin-alias or @rollup/plugin-json), but not all of them, since some plugin hooks do not make sense in an unbundled dev server context. In general, as long as a Rolldown / Rollup plugin fits the following criteria then it should just work as a Vite plugin: It doesn't use the moduleParsed hook. It doesn't rely on the Rolldown specific options like transform.inject It doesn't have strong coupling between bundle-phase hooks and output-phase hooks. If a Rolldown / Rollup plugin only makes sense for the build phase, then it can be specified under build.rolldownOptions.plugins instead. It will work the same as a Vite plugin with enforce: 'post' and apply: 'build'. You can also augment an existing Rolldown / Rollup plugin with Vite-only properties: import example from 'rolldown-plugin-example' import { defineConfig } from 'vite' export default defineConfig({ plugins: [ { ...example(), enforce: 'post', apply: 'build', }, ], }) Path Normalization Vite normalizes paths while resolving ids to use POSIX separators ( / ) while preserving the volume in Windows. On the other hand, Rollup keeps resolved paths untouched by default, so resolved ids have win32 separators ( \ ) in Windows. However, Rollup plugins use a normalizePath utility function from @rollup/pluginutils internally, which converts separators to POSIX before performing comparisons. This means that when these plugins are used in Vite, the include and exclude config pattern and other similar paths against resolved ids comparisons work correctly. So, for Vite plugins, when comparing paths against resolved ids it is important to first normalize the paths to use POSIX separators. An equivalent normalizePath utility function is exported from the vite module. import { normalizePath } from 'vite' normalizePath('foo\\bar') // 'foo/bar' normalizePath('foo/bar') // 'foo/bar' Filtering, include/exclude pattern Vite exposes @rollup/pluginutils's createFilter function to encourage Vite specific plugins and integrations to use the standard include/exclude filtering pattern, which is also used in Vite core itself. Hook Filters Rolldown introduced a hook filter feature to reduce the communication overhead between the Rust and JavaScript runtimes. This feature allows plugins to specify patterns that determine when hooks should be called, improving performance by avoiding unnecessary hook invocations. This is also supported by Rollup 4.38.0+ and Vite 6.3.0+. To make your plugin backward compatible with older versions, make sure to also run the filter inside the hook handlers. export default function myPlugin() { const jsFileRegex = /\.js$/ return { name: 'my-plugin', // Example: only call transform for .js files transform: { filter: { id: jsFileRegex, }, handler(code, id) { // Additional check for backward compatibility if (!jsFileRegex.test(id)) return null return { code: transformCode(code), map: null, } }, }, } } ::: tip @rolldown/pluginutils exports some utilities for hook filters like exactRegex and prefixRegex. These are also re-exported from rolldown/filter for convenience. ::: Chunk Import Map Information :::info Experimental This feature is experimental and may change in the future. ::: When build.chunkImportMap option is enabled, the import statements in the generated chunks will use a unique ID for each chunk instead of the file path. To get the mapping from the chunk ID to the file path, you can access the import map emitted to the bundle in the generateBundle hook or the writeBundle hook. The import map has the name specified by build.rolldownOptions.experimental.chunkImportMap.fileName (defaults to importmap.json). function accessImportMap() { let config: ResolvedConfig return { name: 'access-import-map', configResolved(resolvedConfig) { config = resolvedConfig }, generateBundle(options, bundle) { const chunkImportMap = config.build.rolldownOptions.experimental?.chunkImportMap if (chunkImportMap) { const importMapFilename = typeof chunkImportMap === 'object' && chunkImportMap.fileName ? chunkImportMap.fileName : 'importmap.json' const importMap = bundle[importMapFilename]! as OutputAsset const mapping = JSON.parse(importMap.source).imports console.log(mapping) // { "./entry.hash1.js": "./entry.hash2.js" } } }, } } Client-server Communication Since Vite 2.9, we provide some utilities for plugins to help handle the communication with clients. Server to Client On the plugin side, we could use server.ws.send to broadcast events to the client: export default defineConfig({ plugins: [ { // ... configureServer(server) { server.ws.on('connection', () => { server.ws.send('my:greetings', { msg: 'hello' }) }) }, }, ], }) ::: tip NOTE We recommend always prefixing your event names to avoid collisions with other plugins. ::: On the client side, use hot.on to listen to the events: import 'vite/client' // ---cut--- // client side if (import.meta.hot) { import.meta.hot.on('my:greetings', (data) => { console.log(data.msg) // hello }) } Client to Server To send events from the client to the server, we can use hot.send: // client side if (import.meta.hot) { import.meta.hot.send('my:from-client', { msg: 'Hey!' }) } Then use server.ws.on and listen to the events on the server side: export default defineConfig({ plugins: [ { // ... configureServer(server) { server.ws.on('my:from-client', (data, client) => { console.log('Message from client:', data.msg) // Hey! // reply only to the client (if needed) client.send('my:ack', { msg: 'Hi! I got your message!' }) }) }, }, ], }) TypeScript for Custom Events Internally, vite infers the type of a payload from the CustomEventMap interface, it is possible to type custom events by extending the interface: :::tip Note Make sure to include the .d.ts extension when specifying TypeScript declaration files. Otherwise, TypeScript may not know which file the module is trying to extend. ::: import 'vite/types/customEvent.d.ts' declare module 'vite/types/customEvent.d.ts' { interface CustomEventMap { 'custom:foo': { msg: string } // 'event-key': payload } } This interface extension is utilized by InferCustomEventPayload to infer the payload type for event T. For more information on how this interface is utilized, refer to the HMR API Documentation. import 'vite/client' import type { InferCustomEventPayload } from 'vite/types/customEvent.d.ts' declare module 'vite/types/customEvent.d.ts' { interface CustomEventMap { 'custom:foo': { msg: string } } } // ---cut--- type CustomFooPayload = InferCustomEventPayload<'custom:foo'> import.meta.hot?.on('custom:foo', (payload) => { // The type of payload will be { msg: string } }) import.meta.hot?.on('unknown:event', (payload) => { // The type of payload will be any }) Static Asset Handling Related: Public Base Path Related: assetsInclude config option Importing Asset as URL Importing a static asset will return the resolved public URL when it is served: import 'vite/client' // ---cut--- import imgUrl from './img.png' document.getElementById('hero-img').src = imgUrl For example, imgUrl will be /src/img.png during development, and become /assets/img.2d8efhg.png in the production build. The behavior is similar to webpack's file-loader. The difference is that the import can be either using absolute public paths (based on project root during dev) or relative paths. url() references in CSS are handled the same way. If using the Vue plugin, asset references in Vue SFC templates are automatically converted into imports. Common image, media, and font filetypes are detected as assets automatically. You can extend the internal list using the assetsInclude option. Referenced assets are included as part of the build assets graph, will get hashed file names, and can be processed by plugins for optimization. Assets smaller in bytes than the assetsInlineLimit option will be inlined as base64 data URLs. Git LFS placeholders are automatically excluded from inlining because they do not contain the content of the file they represent. To get inlining, make sure to download the file contents via Git LFS before building. TypeScript, by default, does not recognize static asset imports as valid modules. To fix this, include vite/client. ::: tip Inlining SVGs through url() When passing a URL of SVG to a manually constructed url() by JS, the variable should be wrapped within double quotes. import 'vite/client' // ---cut--- import imgUrl from './img.svg' document.getElementById('hero-img').style.background = `url("${imgUrl}")` ::: Explicit URL Imports Assets that are not included in the internal list or in assetsInclude can be explicitly imported as a URL using the ?url suffix. This is useful, for example, to import Houdini Paint Worklets. import 'vite/client' // ---cut--- import workletURL from 'extra-scalloped-border/worklet.js?url' CSS.paintWorklet.addModule(workletURL) Explicit Inline Handling Assets can be explicitly imported with inlining or no inlining using the ?inline or ?no-inline suffix respectively. import 'vite/client' // ---cut--- // prettier-ignore import imgUrl1 from './img.svg?no-inline' import imgUrl2 from './img.png?inline' Importing Asset as String Assets can be imported as strings using the ?raw suffix. import 'vite/client' // ---cut--- import shaderString from './shader.glsl?raw' Importing Script as a Worker Scripts can be imported as web workers with the ?worker or ?sharedworker suffix. import 'vite/client' // ---cut--- // Separate chunk in the production build import Worker from './shader.js?worker' const worker = new Worker() import 'vite/client' // ---cut--- // sharedworker import SharedWorker from './shader.js?sharedworker' const sharedWorker = new SharedWorker() import 'vite/client' // ---cut--- // Inlined as base64 strings import InlineWorker from './shader.js?worker&inline' Check out the Web Worker section for more details. The public Directory If you have assets that are: Never referenced in source code (e.g. robots.txt) Must retain the exact same file name (without hashing) ...or you simply don't want to have to import an asset first just to get its URL Then you can place the asset in a special public directory under your project root. Assets in this directory will be served at root path / during dev, and copied to the root of the dist directory as-is. The directory defaults to /public, but can be configured via the publicDir option. Note that you should always reference public assets using root absolute path - for example, public/icon.png should be referenced in source code as /icon.png. ::: tip Choosing between imports and the public directory In general, prefer importing assets unless you specifically need the guarantees provided by the public directory. ::: new URL(url, import.meta.url) import.meta.url is a native ESM feature that exposes the current module's URL. Combining it with the native URL constructor, we can obtain the full, resolved URL of a static asset using relative path from a JavaScript module: const imgUrl = new URL('./img.png', import.meta.url).href document.getElementById('hero-img').src = imgUrl This works natively in modern browsers - in fact, Vite doesn't need to process this code at all during development! This pattern also supports dynamic URLs via template literals: function getImageUrl(name) { // note that this does not include files in subdirectories return new URL(`./dir/${name}.png`, import.meta.url).href } During the production build, Vite will perform necessary transforms so that the URLs still point to the correct location even after bundling and asset hashing. However, the URL string must be static so it can be analyzed, otherwise the code will be left as is, which can cause runtime errors if build.target does not support import.meta.url. // Vite will not transform this const imgUrl = new URL(imagePath, import.meta.url).href ::: details How it works Vite will transform the getImageUrl function to: import __img0png from './dir/img0.png' import __img1png from './dir/img1.png' function getImageUrl(name) { const modules = { './dir/img0.png': __img0png, './dir/img1.png': __img1png, } return new URL(modules[`./dir/${name}.png`], import.meta.url).href } ::: ::: warning Does not work with SSR This pattern does not work if you are using Vite for Server-Side Rendering, because import.meta.url has different semantics in browsers vs. Node.js. The server bundle also cannot determine the client host URL ahead of time. ::: Backend Integration :::tip Note If you want to serve the HTML using a traditional backend (e.g. Rails, Laravel) but use Vite for serving assets, check for existing integrations listed in Awesome Vite. If you need a custom integration, you can follow the steps in this guide to configure it manually. ::: In your Vite config, configure the entry and enable build manifest: import { defineConfig } from 'vite' // ---cut--- export default defineConfig({ // overwrite default .html entry input: '/path/to/main.js', server: { cors: { // the origin you will be accessing via browser origin: 'http://my-backend.example.com', }, }, build: { // generate .vite/manifest.json in outDir manifest: true, }, }) If you haven't disabled the module preload polyfill, you also need to import the polyfill in your entry // add the beginning of your app entry import 'vite/modulepreload-polyfill' For development, inject the following in your server's HTML template (substitute http://localhost:5173 with the local URL Vite is running at): In order to properly serve assets, you have two options: Make sure the server is configured to proxy static assets requests to the Vite server Set server.origin so that generated asset URLs will be resolved using the back-end server URL instead of a relative path This is needed for assets such as images to load properly. Note if you are using React with @vitejs/plugin-react, you'll also need to add this before the above scripts, since the plugin is not able to modify the HTML you are serving (substitute http://localhost:5173 with the local URL Vite is running at): For production, after running vite build, a .vite/manifest.json file will be generated alongside other asset files. An example manifest file looks like this: { "_shared-B7PI925R.js": { "file": "assets/shared-B7PI925R.js", "name": "shared", "css": ["assets/shared-ChJ_j-JJ.css"] }, "_shared-ChJ_j-JJ.css": { "file": "assets/shared-ChJ_j-JJ.css", "src": "_shared-ChJ_j-JJ.css" }, "logo.svg": { "file": "assets/logo-BuPIv-2h.svg", "src": "logo.svg" }, "baz.js": { "file": "assets/baz-B2H3sXNv.js", "name": "baz", "src": "baz.js", "isDynamicEntry": true }, "views/bar.js": { "file": "assets/bar-gkvgaI9m.js", "name": "bar", "src": "views/bar.js", "isEntry": true, "imports": ["_shared-B7PI925R.js"], "dynamicImports": ["baz.js"] }, "views/foo.js": { "file": "assets/foo-BRBmoGS9.js", "name": "foo", "src": "views/foo.js", "isEntry": true, "imports": ["_shared-B7PI925R.js"], "css": ["assets/foo-5UjPuW-k.css"] } } The manifest maps source files to their build outputs and dependencies: digraph manifest { rankdir=TB node [shape=box style="rounded,filled" fontname="Arial" fontsize=10 margin="0.2,0.1" fontcolor="${#3c3c43|#ffffff}" color="${#c2c2c4|#3c3f44}"] edge [color="${#67676c|#98989f}" fontname="Arial" fontsize=9 fontcolor="${#67676c|#98989f}"] bgcolor="transparent" foo [label="views/foo.js\n(entry)" fillcolor="${#e9eaff|#222541}"] bar [label="views/bar.js\n(entry)" fillcolor="${#e9eaff|#222541}"] shared [label="_shared-B7PI925R.js\n(common chunk)" fillcolor="${#f2ecfc|#2c273e}"] baz [label="baz.js\n(dynamic import)" fillcolor="${#fcf4dc|#38301a}"] foocss [label="foo.css" shape=ellipse fillcolor="${#fde4e8|#3a1d27}"] sharedcss [label="shared.css" shape=ellipse fillcolor="${#fde4e8|#3a1d27}"] logo [label="logo.svg\n(asset)" shape=ellipse fillcolor="${#def5ed|#15312d}"] foo -> shared [label="imports"] bar -> shared [label="imports"] bar -> baz [label="dynamicImports" style=dashed] foo -> foocss [label="css"] shared -> sharedcss [label="css"] } The manifest has a Record structure where each chunk follows the ManifestChunk interface: interface ManifestChunk { /** * The input file name of this chunk / asset if known */ src?: string /** * The output file name of this chunk / asset */ file: string /** * The list of CSS files imported by this chunk */ css?: string[] /** * The list of asset files imported by this chunk, excluding CSS files */ assets?: string[] /** * Whether this chunk or asset is an entry point */ isEntry?: boolean /** * The name of this chunk / asset if known */ name?: string /** * Whether this chunk is a dynamic entry point * * This field is only present in JS chunks. */ isDynamicEntry?: boolean /** * The list of statically imported chunks by this chunk * * The values are the keys of the manifest. This field is only present in JS chunks. */ imports?: string[] /** * The list of dynamically imported chunks by this chunk * * The values are the keys of the manifest. This field is only present in JS chunks. */ dynamicImports?: string[] } Each entry in the manifest represents one of the following: Entry chunks: Generated from files specified in build.rolldownOptions.input. These chunks have isEntry: true and their key is the relative src path from project root. Dynamic entry chunks: Generated from dynamic imports. These chunks have isDynamicEntry: true and their key is the relative src path from project root. Non-entry chunks: Their key is the base name of the generated file prefixed with _. Asset chunks: Generated from imported assets like images, fonts. Their key is the relative src path from project root. CSS files: When build.cssCodeSplit is false, a single CSS file is generated with the key style.css. When build.cssCodeSplit is not false, the key is generated similar to JS chunks (i.e. entry chunks will not have _ prefix and non-entry chunks will have _ prefix). JS chunks (chunks other than assets or CSS) will contain information on their static and dynamic imports (both are keys that map to the corresponding chunk in the manifest). Chunks also list their corresponding CSS and asset files if they have any. You can use this file to render links or preload directives with hashed filenames. Here is an example HTML template to render the proper links. The syntax here is for explanation only, substitute with your server templating language. The importedChunks function is for illustration and isn't provided by Vite. Specifically, a backend generating HTML should include the following tags given a manifest file and an entry point. Note that following this order is recommended for optimal performance: A tag for each file in the entry point chunk's css list (if it exists) Recursively follow all chunks in the entry point's imports list and include a tag for each CSS file of each imported chunk's css list (if it exists). A tag for the file key of the entry point chunk. This can be While the following should be included for the entry point views/bar.js: ::: details Pseudo implementation of importedChunks An example pseudo implementation of importedChunks in TypeScript (This will need to be adapted for your programming language and templating language): import type { Manifest, ManifestChunk } from 'vite' export default function importedChunks( manifest: Manifest, name: string, ): ManifestChunk[] { const seen = new Set() function getImportedChunks(chunk: ManifestChunk): ManifestChunk[] { const chunks: ManifestChunk[] = [] for (const file of chunk.imports ?? []) { const importee = manifest[file] if (seen.has(file)) { continue } seen.add(file) chunks.push(...getImportedChunks(importee)) chunks.push(importee) } return chunks } return getImportedChunks(manifest[name]) } ::: :::info Chunk Import Maps Support (Experimental) If you are using the experimental build.chunkImportMap option, you also need to inject the import map into the HTML. The import map is output to importmap.json in the output directory. Make sure to inject the To opt-out of HTML processing on certain elements, you can add the vite-ignore attribute on the element, which can be useful when referencing external assets or CDN. Frameworks All modern frameworks maintain integrations with Vite. Most framework plugins are maintained by each framework team, with the exception of the official Vue and React Vite plugins that are maintained in the vite org: Vue support via @vitejs/plugin-vue Vue JSX support via @vitejs/plugin-vue-jsx React support via @vitejs/plugin-react React using SWC support via @vitejs/plugin-react-swc React Server Components (RSC) support via @vitejs/plugin-rsc Check out the Plugins Guide for more information. JSX .jsx and .tsx files are also supported out of the box. JSX transpilation is also handled via Oxc Transformer. Your framework of choice will already configure JSX out of the box (for example, Vue users should use the official @vitejs/plugin-vue-jsx plugin, which provides Vue 3 specific features including HMR, global component resolving, directives and slots). If using JSX with your own framework, custom jsxFactory and jsxFragment can be configured using the oxc option. For example, the Preact plugin would use: import { defineConfig } from 'vite' export default defineConfig({ oxc: { jsx: { importSource: 'preact', }, }, }) More details in Oxc Transformer docs. You can inject the JSX helpers using jsxInject (which is a Vite-only option) to avoid manual imports: import { defineConfig } from 'vite' export default defineConfig({ oxc: { jsxInject: `import React from 'react'`, }, }) CSS Importing .css files will inject its content to the page via a