Vite plugin for Craft CMS 3.x

Allows the use of the Vite.js next generation frontend tooling with Craft CMS

Related Article: Vite.js Next Generation Frontend Tooling + Craft CMS


This plugin requires Craft CMS 3.0.0 or later.


To install the plugin, follow these instructions.

  1. Open your terminal and go to your Craft project:

     cd /path/to/project
  2. Then tell Composer to load the plugin:

     composer require nystudio107/craft-vite
  3. In the Control Panel, go to Settings → Plugins and click the “Install” button for Vite.

Vite Overview

Vite is a bridge between Craft CMS/Twig and the next generation frontend build tool Vite.js

Vite allows for Hot Module Replacement (HMR) of JavaScript, CSS, and Twig (even through errors) during development, as well as optimized production builds.

Vite supports both modern and legacy bundle builds, as per the Deploying ES2015+ Code in Production Today article.

Vite also handles generating the necessary <script> and <link> tags to support both synchronous and asynchronous loading of JavaScript and CSS.

Additionally, Vite has a caching layer to ensure optimal performance.

Configuring Vite

Vite.js is JavaScript frontend tooling, so by default it uses an index.html as an entrypoint to your application, but with the Vite plugin and some minor configuration changes, we can use it with server-rendered setups like Craft CMS.

Configuration for Vite is done via the config.php config file. Here’s the default config.php; it should be renamed to vite.php and copied to your config/ directory to take effect.

The config.php File


use craft\helpers\App;

return [
    'useDevServer' => App::env('ENVIRONMENT') === 'dev',
    'manifestPath' => '@webroot/dist/manifest.json',
    'devServerPublic' => 'http://localhost:3000/',
    'serverPublic' => App::env('PRIMARY_SITE_URL') . '/dist/',
    'errorEntry' => '',
    'cacheKeySuffix' => '',
    'devServerInternal' => '',
    'checkDevServer' => false,
    'includeReactRefreshShim' => false,
    'includeModulePreloadShim' => true,
    'criticalPath' => '@webroot/dist/criticalcss',
    'criticalSuffix' =>'_critical.min.css',

These are the settings you’ll need to change for your project:

  • useDevServer - is a boolean that sets whether you will be using Vite dev server for hot module replacement ( HMR). If this is set to false, the files will be pulled from the manifest.json specified in manifestPath
  • manifestPath - the public server path to your manifest files; it can be a full URL or a partial path, or a Yii2 alias. This is usually the same as whatever you set your build.outDir to in vite.config.js
  • devServerPublic - the URL to the Vite dev server, which is used for the hot module replacement (HMR); it can be a full URL or a partial path, or a Yii2 alias. Usually this is http://localhost:3000, since Vite defaults to that. This will appear in <script> tags on the frontend when the dev server is running
  • serverPublic - the public server URL to your asset files; it can be a full URL or a partial path, or a Yii2 alias. This will appear in <script> tags on the frontend for production builds. App::env('PRIMARY_SITE_URL') . '/dist/' is a typical setting

These are completely optional settings that you probably won’t need to change:

  • errorEntry - is a string, or array of strings, that should be the JavaScript entry point(s) (for example: src/js/app.ts) in your manifest.json that should be injected into Twig error templates, to allow hot module replacement to work through Twig error pages. devMode must be true and useDevServer must also be true for this to have any effect.
  • cacheKeySuffix - String to be appended to the cache key
  • devServerInternal - The internal URL to the dev server, when accessed from the environment in which PHP is executing. This can be the same as $devServerPublic, but may be different in containerized or VM setups. ONLY used if $checkDevServer = true
  • checkDevServer - Should we check for the presence of the dev server by pinging $devServerInternal to make sure it’s running?
  • includeReactRefreshShim - whether or not the required shim for react-refresh should be included when the Vite dev server is running
  • includeModulePreloadShim - whether or not the shim for modulepreload-polyfill should be included to polyfill <link rel="modulepreload">

If you’re using the rollup-plugin-critical to generate critical CSS, use these settings:

  • criticalPath - File system path (or URL) to where the Critical CSS files are stored
  • criticalSuffix - the suffix added to the name of the currently rendering template for the critical CSS filename

Note also that the manifestPath defaults to a Yii2 alias @webroot/dist/manifest.json (adjust as necessary to point to your manifest.json on the file system); this allows Vite to load the manifest from the file system, rather than via http request, and is the preferred method. However, it works fine as a full URL as well if you have your manifest.json hosted on a CDN or such.

Configuring Vite.js

Basic Config

Here’s a basic vite.config.js for use as a Vite config:

export default ({command}) => ({
  base: command === 'serve' ? '' : '/dist/',
  build: {
    manifest: true,
    outDir: '../cms/web/dist/',
    rollupOptions: {
      input: {
        app: './src/js/app.ts',
  • base - set to the root if the dev server is running, and otherwise set it to /dist/ so our built assets are in their own directory (and often not checked into Git).
  • build.manifest - set to true so that the Rollup build will generate a manifest file of the production assets
  • build.outDir - specifies where the built production assets should go, as a file system path relative to the vite.config.js file.
  • build.rollupOptions.input - set to an object that has key-value pairs for each of our entrypoint scripts (needed since we’re not using an index.html as our application entrypoint). These should be the full path to the script as referenced in your Twig code

Modern + Legacy Config

By default, Vite generates JavaScript bundles that work with modern browsers that support native ESM.

If you require support for legacy browsers, you can use the @vitejs/plugin-legacy plugin:

import legacy from '@vitejs/plugin-legacy'

export default ({command}) => ({
  base: command === 'serve' ? '' : '/dist/',
  build: {
    manifest: true,
    outDir: '../cms/web/dist/',
    rollupOptions: {
      input: {
        app: './src/js/app.ts',
  plugins: [
      targets: ['defaults', 'not IE 11']

This will generate -legacy files for your production bundles, and the Vite plugin automatically detects them and uses the module/nomodule pattern for you.

Live Reload of Twig Config

Vite provides hot module replacement (HRM) of CSS and JavaScript as you build your application out of the box.

If you want live reload of your Twig (or other) files as you develop, you can get that with the vite-plugin-restart plugin:

import ViteRestart from 'vite-plugin-restart';

export default ({command}) => ({
  base: command === 'serve' ? '' : '/dist/',
  build: {
    manifest: true,
    outDir: '../cms/web/dist/',
    rollupOptions: {
      input: {
        app: './src/js/app.ts',
  plugins: [
      reload: [
  • plugins.ViteRestart.reload - lets you specify and array of file system paths or globs to watch for changes, and issue a page refresh if any of the files change

The Vite plugin has support for enabling live refresh even through Twig error pages as you develop.

Local Development Environment Setup


If you’re using TLS (https) in local dev, you may get mixed content errors if you don’t change your devServerPublic to https (and you’d need change your server.HTTPS Vite config too).

Then you’ll want to ensure you have a trusted self-signed certificate for your browser, or you can use the vite-plugin-mkcert plugin.

Using Laravel Valet

If you’re using Laravel Valet you may run into issues with https. If so, you can add this config to your vite.config.js (see below):

  server: {
  https: {
    key: fs.readFileSync('localhost-key.pem'),
    cert: fs.readFileSync('localhost.pem'),
  hmr: {
    host: 'localhost',

Using Homestead/VM

If you’re using a VM like Homestead, to get Hot Module Replacement (HMR) working, you’ll need to add this config to your vite.config.js (see below):

  server: {
  host: '',
    watch: {
    usePolling: true,

To work inside of a VM like VirtualBox (which is typically used by Homestead) you need to enable polling.

Using Docker

To work properly with a Docker setup, the needs to be set to so that it broadcasts to all available IPv4 addresses in your vite.config.js (see below):

  server: {
    host: '',

Using Nitro

Getting Vite up and running in Nitro has been problematic for some.

Since the state of the Nitro frontend development tooling is in flux, check the Nitro Issues or the #nitro discord channel for the latest.

You have 3 options when running Vite with Nitro:

  1. Run Vite inside of the Nitro Docker container
  2. Run Vite in its own Docker container, using its own separate Dockerfile
  3. Run Vite locally on your computer and configure it to use a port other than 3000

Option 1 is probably the most viable, but Vite requires Node.js 14 or later, and with Nitro the Node.js version is tied to the PHP version of your site. Also people have stated that using nitro npm run dev doesn’t work, but using nitro ssh and then running npm run dev from inside the container does work.

Following the Support for vite configuration GitHub issue is likely a good idea.

If you know Docker, option 2 is a good way to go. You can see an example of how you might do it in the craft-plugin-vite-buildchain repository.

I would generally discourage option 3, because we want to run our development tools inside of our local development environment, and not on locally on our computer.

Using DDEV

To run Vite inside a DDEV container, you’ll have to define a custom service that proxies requests from the frontend to the vite server running inside the VM. This is done by creating a /.ddev/docker-compose.*.yaml file, and exposing an additional port to your project.

Create a file named docker-compose.vite.yaml and save it in your project’s /.ddev folder, with the following contents:

# Override the web container's standard HTTP_EXPOSE and HTTPS_EXPOSE services
# to expose port `3000` of DDEV's web container.
version: '3.6'
      - '3000'

In your vite.config.js, the should to be set to, and server.port set to 3000:

server: {
  host: '',
  port: 3000

With the above set up, Craft Vite will now have access to the devServerInternal via http://localhost:3000, and devServerPublic via Note that devServerPublic can run over http or https, devServerInternal is always http. Your config/vite.php file might thus look like:


use craft\helpers\App;

return [
	'checkDevServer' => true,
	'devServerInternal' => 'http://localhost:3000',
	'devServerPublic' => App::env('PRIMARY_SITE_URL') . ':3000',
	'serverPublic' => App::env('PRIMARY_SITE_URL') . '/dist/',
	'useDevServer' => App::env('ENVIRONMENT') === 'dev',
	// other config settings...

If you’re using the rollup-plugin-critical to generate critical CSS, you must add extra Debian packages to enable Puppeteer Headless Chrome support. Add the following line to your /.ddev/config.yaml file:

webimage_extra_packages: [ gconf-service, libasound2, libatk1.0-0, libcairo2, libgconf-2-4, libgdk-pixbuf2.0-0, libgtk-3-0, libnspr4, libpango-1.0-0, libpangocairo-1.0-0, libx11-xcb1, libxcomposite1, libxcursor1, libxdamage1, libxfixes3, libxi6, libxrandr2, libxrender1, libxss1, libxtst6, fonts-liberation, libappindicator1, libnss3, xdg-utils ]

Then be sure to set criticalUrl to http://localhost as part of your rollup configuration.

Finally note that as of DDEV 1.19 you are able to specify Node.js (and Composer) versions directly via /.ddev/config.yaml. See more at

Vite-Processed Assets

This is cribbed from the Laravel Vite integration docs:

There is currently an unsolved issue when referencing assets in files processed by Vite, such as a Vue or CSS file. In development, URLs will not be properly rewritten.

Additionally, there is currently no way to get the path of a Vite-processed asset (for example an image that was imported in a Vue SFC) from the back-end, since the manifest does not reference the original file path. In most cases, this should not be an issue, as this is not a common use case.

What you can do is leverage the /public Public Directory for static assets in Vite, so the URLs will not get rewritten.

The basic problem is if you have a CSS rule like:

background-image: url('/src/img/woof.jpg');

...and your local dev runs off of something like myhost.test then the image will be referenced as:


...which then resolves to the current host:


...when what you really want is for it to be coming from the Vite dev server:


This is only a problem when you’re using Vite with a backend system like Craft CMS, where the host you run the site from is different from where the Vite dev server runs.

To work around this, as of Vite ^2.6.0 you can use the server.origin config to tell Vite to serve the static assets it builds from the Vite dev server, and not the site server:

  server: {
  origin: 'http://localhost:3000',
  host: '',

This issue was discussed in detail, and fixed via a pull request that was rolled into Vite ^2.6.0 in the form of the origin setting.

Other Config

Vite uses esbuild so it is very fast, and has built-in support for TypeScript and JSX.

Vite also comes with support for a number of plugins that allow you to use:

You can easily use Tailwind CSS with Vite as well, with or without the JIT.

Check out Awesome Vite for other great Vite plugins & resources.

Using Vite

The .script() function

The script() function is the primary tag you will use with Vite. It outputs the JavaScript and CSS tags generated by for your input JavaScript files.


Once the Vite plugin is installed and configured, using it is quite simple. Where you would normally link to a JavaScript file via Twig in a <script> tag, you instead do:

    {{ craft.vite.script("src/js/app.ts") }}

Note that Vite automatically also supports the direct linking to TypeScript (as in the above example), JSX, and other files via plugins. You just link directly to them, that’s it.


If the Vite dev server is running, this will cause it to output something like this:

<script type="module" src="http://localhost:3000/src/js/app.ts"></script>

This is causing it to get the script file from the Vite dev server, with full hot module replacement (HMR) support.


In production or otherwise where the Vite dev server is not running, the output will look something like this:

<script type="module" src="" crossorigin></script>
<link href="" rel="stylesheet" media="print" onload="'all'">
Module Preload tags

The Vite plugin will also generate <link rel="modulepreload"> tags for any script modules that your script output via craft.vite.script() imports. The tags will look like this:

<link href="http://localhost:8000//dist/assets/vendor.a785de16.js" rel="modulepreload" crossorigin>

Preloading helps with performance by telling the browser about what it needs to fetch so that it’s not stuck with nothing to do during those long roundtrips.

Sub-Resource Integrity

If you use a Vite plugin such as vite-plugin-manifest-sri, the Craft Vite plugin will include subresource integrity attributes for the <script type="module"> & <link rel="modulepreload"> tags that it generates as well. For example:

<script type="module" src="http://localhost:3000/src/js/app.ts"

Subresource Integrity (SRI) is a security feature that enables browsers to verify that resources they fetch are delivered without unexpected manipulation. It works by allowing you to provide a cryptographic hash that a fetched resource must match.

Script onload events

The <script> tags generated by Vite will automatically fire a vite-script-loaded event dispatched to document so listeners can be notified after a script loads.

If you have JavaScript that needs to be executed after one of your scripts are loaded, you can listen for this event:

document.addEventListener('vite-script-loaded', function (e) {
  if (e.detail.path === 'src/js/app.ts') {
    // The script with the path src/js/app.ts is now loaded, do whatever initialization is needed


To use CSS with Vite, you must import it in one of your build.input JavaScript file entries listed in the vite.config.js, for example:

import '/src/css/app.pcss';

The Vite plugin will take care of automatically generating the <link rel="stylesheet"> tag for you in production.

By default, it loads the CSS asynchronously, but you can configure this. See the Other Options section.


To work properly, you must also import the Vite Polyfill in your build.input JavaScript file entries listed in the vite.config.js, for example:

import "vite/dynamic-import-polyfill";


If you’re using the vite-plugin-legacy plugin to generate builds compatible with older browsers, the Vite plugin will automatically detect this and use the module/nomodule pattern when outputting production build tags.

The Twig code:

    {{ craft.vite.script("src/js/app.ts") }}

Would then result in the following output, depending on whether the Vite dev server is running:


If the Vite dev server is running, this will cause it to output something like this:

<script type="module" src="http://localhost:3000/src/js/app.ts"></script>

This is causing it to get the script file from the Vite dev server, with full hot module replacement (HMR) support.


In production or otherwise where the Vite dev server is not running, the output will look something like this:

    !function () {
        var e = document, t = e.createElement("script");
        if (!("noModule" in t) && "onbeforeload" in t) {
            var n = !1;
            e.addEventListener("beforeload", function (e) {
                if ( === t) n = !0; else if (!"nomodule") || !n) return;
            }, !0), t.type = "module", t.src = ".", e.head.appendChild(t), t.remove()
<script type="nomodule" src=""></script>
<script type="module" src="" crossorigin></script>
<link href="" rel="stylesheet" media="print" onload="'all'">
<script type="nomodule" src=""></script>

So that includes:

  • Safari 10.1 nomodule fix script
  • Legacy nomodule polyfills for dynamic imports for older browsers
  • Modern app.js module for modern browsers
  • Extracted CSS
  • Legacy app.js script for legacy browsers

The .register() function

In addition to the craft.vite.script() function, the Vite plugin also provides a .register() function:

    {{ craft.vite.register("src/js/app.ts") }}

This works exactly the way the .script() function works, but instead of outputting the tags, it registers them with the Craft::$app->getView().

This is primarily useful in plugins that must exist inside of the CP, or other things that leverage the Yii2 AssetBundles and dependencies.

The .entry() function

The Vite plugin includes an .entry() function that retrieves the URL to the entry in the manifest.json file. This function will not ever return a URL from the devServer.

You pass in a relative path to the entry, just as you do for JavaScript files in Vite. For example:

    {{ craft.vite.entry("app.css") }}

This will return a URL like this in regardless of whether the devServer is running or not:


The .asset() function

The Vite plugin includes a .asset() function that retrieves an asset served via Vite in your templates. Assets served from Vite include images or fonts that are referenced via CSS, or are imported via JavaScript.

You pass in a relative path to the asset, just as you do for JavaScript files in Vite. For example:

    {{ craft.vite.asset("src/images/quote-open.svg") }}

This will return a URL like this when the Vite dev server is running:


...and a URL like this in production when the Vite dev server is not running:


N.B.: this is only for assets referenced via CSS or imported via JavaScript. For other static assets, you can either put them in the Vite public directory or you can use the rollup-plugin-copy to copy the assets into your public dist/ directory:

import copy from 'rollup-plugin-copy';

export default ({command}) => ({
  plugins: [
      targets: [{src: 'src/fonts/**/*', dest: '../cms/web/dist/fonts'}],
      hook: 'writeBundle'

The .inline() function

The Vite plugin also includes a .inline() function that inlines the contents of a local file (via path) or remote file (via URL) in your templates.

Yii2 aliases and/or environment variables may be used, and a caching layer is used so that remote files will be kept in the cache until it is cleared, for performance reasons.

URL example:

    {{ craft.vite.inline("") }}

Path example:

    {{ craft.vite.inline("@webroot/my-file.txt") }}

The .devServerRunning() function

The Vite plugin has a .devServerRunning() function that allows you to determine if the Vite dev server is running from your Twig templates.

For instance, you could do:

{% if craft.vite.devServerRunning() %}
   <base href="{{ alias('@viteBaseUrl') }}">
{% endif %}

To side-step the Vite Processed Assets issue.

The .includeCriticalCssTags() function

The Vite plugin includes a .includeCriticalCssTags() function that will look for a file in criticalPath that matches the name of the currently rendering template, with criticalSuffix appended to it.

Used in combination with the rollup-plugin-critical plugin, this automates the inclusion of critical CSS. For example:

    {{ craft.vite.includeCriticalCssTags() }}

To pass in your own path to the CSS that should be included, you can do that via:

    {{ craft.vite.includeCriticalCssTags("/path/to/file.css") }}

...and you can also pass in attributes to be added to the <style> tag as well:

    {{ craft.vite.includeCriticalCssTags(null, {
         'data-css-info': 'bar',
    }) }}

If null is passed in as the first parameter, it’ll use the automatic template matching to determine the filename.

The .getCssHash() function

Pass in the path to your entrypoint script, and it will return the hash of the CSS asset:

   {% set cssHash = craft.vite.getCssHash("src/js/app.ts") %}

If the CSS file in the manifest has the name app.245485b3.css, the above function will return 245485b3.

This can be used for critical CSS patterns, for example:

{# -- Critical CSS -- #}
# Use Nginx Server Sider Includes (SSI) to render different HTML depending on
# the value in the `critical-css` cookie. ref:
{% set cssHash = craft.vite.getCssHash("src/js/app.ts") %}
 # If the `critical-css` cookie is set, the client already has the CSS file download,
 # so don't include the critical CSS, and load the full stylesheet(s) synchronously
<!--# if expr="$HTTP_COOKIE=/critical\-css\={{ cssHash }}/" -->
{{ craft.vite.script("src/js/app.ts", false) }}
<!--# else -->
# If the cookie is not set, set the cookie, then include the critical CSS for this page,
# and load the full stylesheet(s) asychronously
     Cookie.set("critical-css", "{{ cssHash }}", { expires: "7D", secure: true });
{{ craft.vite.includeCriticalCssTags() }}
{{ craft.vite.script("src/js/app.ts", true) }}
<!--# endif -->

Other Options

The .script() and .register() functions accept additional options as well:

    {{ craft.vite.script(PATH, ASYNC_CSS, SCRIPT_TAG_ATTRS, CSS_TAG_ATTRS) }}
  • PATH - string - the path to the script
  • ASYNC_CSS - boolean - whether any CSS should be loaded async or not (defaults to true)
  • SCRIPT_TAG_ATTRS - array - an array of key-value pairs for additional attributes to add to any generated script tags
  • CSS_TAG_ATTRS - array - an array of key-value pairs for additional attributes to add to any generated CSS link tags

So for example:

    {{ craft.vite.script(
        { 'data-script-info': 'foo' },
        { 'data-css-info': 'bar' },
    ) }}

Vite Roadmap

Some things to do, and ideas for potential features:

Brought to you by nystudio107