Andrew Welch · #vite #webpack #frontend

Published , updated · 5 min read ·

Please consider 🎗 sponsoring me 🎗 to keep writing articles like this.

Vite.js Next Generation Frontend Tooling + Craft CMS

Vite.js is next gen­er­a­tion fron­tend tool­ing for mod­ern JavaScript that com­bines speed with sim­plic­i­ty. This arti­cle shows you how to inte­grate Vite.js with Craft CMS

Vite.js is next gen­er­a­tion fron­tend tool­ing that can replace web­pack, Mix, Gulp, etc. for build­ing fron­tend assets like JavaScript and CSS.

In this arti­cle, we’ll explore how you can lever­age Vite.js as a fast, sim­ple build sys­tem for your Craft CMS websites.

Vite.js is a fast & opinionated… but its opinions are quite good

Vite.js is designed to work with the JavaScript & Node.js ecosys­tems, but using the Vite plu­g­in I wrote, we can use it for Craft CMS too.

Vite.js has fast start­up times because it lever­ages ES Mod­ules in mod­ern browsers rather than bundling, and it uses esbuild under the hood for fast tran­spi­la­tion of Type­Script, JSX, and more.

If you want to try out a live, ful­ly func­tion­ing web­site your­self that uses Vite.js, check out the Spoke & Chain GitHub repos­i­to­ry. Com­pare & con­trast this with the Europa Muse­um GitHub repos­ti­to­ry, which has the same archi­tec­ture, but uses web­pack 5 for the buildchain.

Link Why Vite.js?

The pri­ma­ry rea­sons to use Vite.js are speed and simplicity.

It’s fast to start up, the hot mod­ule replace­ment (HMR) is near-instan­ta­neous, and the pro­duc­tion build times are fast too.

It’s also quite sim­ple to set up, there isn’t much con­fig­u­ra­tion to be done, and using it involves just link­ing to the script you want to use.

Vite.js combines the simplicity of a script tag with the power of modern JavaScript

Instead of set­ting up an elab­o­rate Babel pipeline, you just put a <script src="src/js/app.ts"> in your code, and like mag­ic you can use TypeScript.

While it is tough to pre­dict future trends, Vite.js also seems to be trend­ing in the right direc­tion rel­a­tive­ly to the new crop of bundling tools.

par­cel vs vite vs snowpack

Link Configuring the Vite plugin for Craft CMS

Vite.js is JavaScript fron­tend tool­ing, so by default it uses an index.html as an entry­point to your appli­ca­tion.

That does­n’t make sense in the con­text of a serv­er-ren­dered set­up like Craft CMS, but with the Vite plu­g­in and some minor con­fig­u­ra­tion changes, it works like a charm.

The first step is to install the plugin:

composer require nystudio107/craft-vite

You can also install the plu­g­in via the Plu­g­in Store in the Craft CP as well.

Next we need to copy the file config.php from vendor/nystudio107/craft-vite/src/ to the Craft config/ direc­to­ry as vite.php (so rename the file in the process).

Here’s what the file will look like:

 * Vite plugin for Craft CMS 3.x
 * Allows the use of the Vite.js next generation frontend tooling with Craft CMS
 * @link
 * @copyright Copyright (c) 2021 nystudio107

use craft\helpers\App;

 * Vite config.php
 * This file exists only as a template for the Vite settings.
 * It does nothing on its own.
 * Don't edit this file, instead copy it to 'craft/config' as 'vite.php'
 * and make your changes there to override default settings.
 * Once copied to 'craft/config', this file will be multi-environment aware as
 * well, so you can have different settings groups for each environment, just as
 * you do for 'general.php'

return [

    * @var bool Should the dev server be used?
    'useDevServer' => App::env('DEV_MODE'),

     * @var string File system path (or URL) to the Vite-built manifest.json
    'manifestPath' => '@webroot/dist/manifest.json',

     * @var string The public URL to the dev server (what appears in `<script src="">` tags
    'devServerPublic' => 'http://localhost:3000/',

     * @var string The public URL to use when not using the dev server
    'serverPublic' => App::env('PRIMARY_SITE_URL') . '/dist/',

     * @var string The JavaScript entry from the manifest.json to inject on Twig error pages
     *              This can be a string or an array of strings
    'errorEntry' => '',

     * @var string String to be appended to the cache key
    'cacheKeySuffix' => '',

     * @var string 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
    'devServerInternal' => '',

     * @var bool Should we check for the presence of the dev server by pinging $devServerInternal to make sure it's running?
    'checkDevServer' => false,

You’ll need to make a few changes to this file depend­ing on what your local devel­op­ment envi­ron­ment looks like.

These are the set­tings you’ll need to change for your project:

  • useDevServer — is a boolean that sets whether you will be using Vite dev serv­er for hot mod­ule replace­ment (HMR). This defaults to using the DEV_MODE envi­ron­ment vari­able, so it’ll be on when in dev­Mode, and off when not.
  • manifestPath — the pub­lic serv­er path to your man­i­fest files; it can be a full URL or a par­tial path, or a Yii2 alias. This is usu­al­ly the same as what­ev­er you set your build.outDir to in vite.config.js
  • devServerPublic — the URL to the Vite dev serv­er, which is used for the hot mod­ule replace­ment (HMR); it can be a full URL or a par­tial path, or a Yii2 alias. Usu­al­ly this is http://localhost:3000, since Vite defaults to that. This will appear in <script> tags on the fron­tend when the dev serv­er is running
  • serverPublic — the pub­lic serv­er URL to your asset files; it can be a full URL or a par­tial path, or a Yii2 alias. This will appear in <script> tags on the fron­tend for pro­duc­tion builds. App::env('PRIMARY_SITE_URL') . '/dist/' is a typ­i­cal setting
These are com­plete­ly option­al set­tings that you prob­a­bly won’t need to change:
  • errorEntry — is a string, or array of strings, that should be the JavaScript entry point(s) (e.g.: src/js/app.js) that should be inject­ed into Twig error tem­plates, to allow hot mod­ule replace­ment 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 append­ed to the cache key
  • devServerInternal — The inter­nal URL to the dev serv­er, when accessed from the envi­ron­ment in which PHP is exe­cut­ing. This can be the same as devServerPublic, but may be dif­fer­ent in con­tainer­ized or VM setups. ONLY used if checkDevServer = true
  • checkDevServer — Should we check for the pres­ence of the dev serv­er by ping­ing devServerInternal to make sure it’s running?

See the Vite doc­u­men­ta­tion if you need more help set­ting this up for your local dev environment.

Link Vite.js configuration

Next we need to set up Vite.js itself. First you’ll need a package.json that might look some­thing like this:

  "name": "vite",
  "devDependencies": {
    "@vitejs/plugin-legacy": "^1.3.3",
    "autoprefixer": "^10.2.5",
    "postcss": "^8.2.12",
    "tailwindcss": "^2.1.2",
    "vite": "^2.7.0",
    "vite-plugin-restart": "0.0.2"
  "scripts": {
    "dev": "vite",
    "build": "vite build"

These are the pack­ages you’ll need to set up a bare-bones Vite.js set­up that works with Tail­wind CSS. If you want to use Vue.js or React or Svelte or some oth­er fron­tend frame­work, you’ll just add those pack­ages and the appro­pri­ate Vite.js plu­g­ins.

Then do an npm install to install your pack­ages, and then gen­er­ate default tailwind.config.js and postcss.config.js files with:

npx tailwindcss init -p

Next we’ll cre­ate a vite.config.js file for our Vite.js configuration:

import legacy from '@vitejs/plugin-legacy'
import ViteRestart from 'vite-plugin-restart';

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

The @vitejs/plugin-legacy plu­g­in gen­er­ates a lega­cy bun­dle for non-ESM native browsers, and the Vite plu­g­in takes care of gen­er­at­ing the appro­pri­ate module/​nomodule tags for you.

The vite-plu­g­in-restart plu­g­in allows us to get live reload for our Twig tem­plates or oth­er files, in addi­tion to the out of the box HMR for JavaScript and CSS. Just pass in as many path globs as you like.

Oth­er set­tings of note:

  • base — set to the root if the dev serv­er is run­ning, and oth­er­wise set it to /dist/ so our built assets are in their own direc­to­ry (and often not checked into Git).
  • build.emptyOutDir — always delete every­thing from the outDir before building
  • build.manifest — set to true so that the Rollup build will gen­er­ate a man­i­fest file of the pro­duc­tion assets
  • build.outDir — spec­i­fies where the built pro­duc­tion assets should go, as a file sys­tem path rel­a­tive to the vite.config.js file.
  • build.rollupOptions.input — set to an object that has key/​value pairs for each of our entry­point scripts (need­ed since we’re not using an index.html as our appli­ca­tion entry­point). The lead­ing ./ is impor­tant here, as absolute paths seem to break Com­mon­JS imports
  • — this is set to so that it broad­casts to all IPv4 address­es on the local machine, need­ed as of Vite 2.3.0 or lat­er for Dock­er and VM-based setups 

Then to start up the Vite.js dev serv­er, you just do:

npm run dev

Your JavaScript files will be served up from http://localhost:3000 while the dev serv­er is running.

When it’s time to do a pro­duc­tion build, you just do:

npm run build

And your pro­duc­tion assets will be built for your via Rollup, ready to deploy.

For more infor­ma­tion on Vite.js itself, check out the excel­lent Vite.js doc­u­men­ta­tion.

Link Using Vite.js with Craft CMS

Cre­ate your JavaScript, Type­Script, Vue sin­gle file com­po­nents, etc. as you nor­mal­ly would. Let’s say we store them in the direc­to­ry /src/js/

Here’s an exam­ple Type­Script file (remem­ber, it could be JavaScript too):

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

console.log("Hello, world!");

Any CSS that we want processed via PostC­SS needs to be import­ed via JavaScript. We don’t include the CSS in our tem­plates via <link rel="stylesheet"> tags, the plu­g­in does it for us.

Side note: as of Vite 2.3.0, the vite/­dy­nam­ic-import-poly­fill is no longer required to be imported.

That’s it. Then in our Twig tem­plates, we just need to ref­er­ence the file via a Twig func­tion pro­vid­ed by the Vite plugin:

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

The paths to your scripts are always rel­a­tive to the project root, which by default is the same direc­to­ry as the vite.config.js

In devel­op­ment with the Vite.js dev serv­er run­ning, this will gen­er­ate a tag that looks like this:

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

The CSS will be inject­ed auto­mat­i­cal­ly into the DOM via inline <style> tags while the dev serv­er is running.

In pro­duc­tion with­out the Lega­cy plu­g­in, the Twig func­tion will gen­er­ate the fol­low­ing tags:

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

Remem­ber that any <script type="module"> scripts are down­loaded with­out block­ing the HTML pars­er, and exe­cut­ed in order after the DOM is parsed, they are non-blocking.

Here’s an image from the Google JavaScript Mod­ules primer on how scripts load:

Script load­ing & parsing

Since we’re using the Lega­cy plu­g­in for dual mod­ern + lega­cy builds, we’ll get even more tags gen­er­at­ed automatically:

    !function(){var e=document,t=e.createElement("script");if(!("noModule"in t)&&"onbeforeload"in t){var n=!1;e.addEventListener("beforeload",function(e){if(!0;else if(!"nomodule")||!n)return;e.preventDefault()},!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>

This includes:

  • Safari 10.1 nomod­ule fix script
  • Lega­cy nomodule poly­fills for dynam­ic imports for old­er browsers
  • Mod­ern app.js mod­ule for mod­ern browsers
  • Extract­ed CSS, loaded asyn­chro­nous­ly by default
  • Lega­cy app.js script for lega­cy browsers

This is the module/​nomodule pat­tern that’s been in use for years to allow for mod­ern and lega­cy JavaScript bundles.

Mod­ules that are dynam­i­cal­ly loaded via one of your entry scripts will appear in the brows­er as <link rel="modulepreload"> — check out the Pre­load­ing Mod­ules for infor­ma­tion on that.

Link Live Reload through Twig Errors

The Vite plu­g­in includes sup­port for live reload­ing through Twig error pages. You just need to set the errorEntry set­ting in the config/vite.php file, e.g.:

    'errorEntry' => 'src/js/app.ts',

Then the page will auto-reload when­ev­er you fix the error!

Link Vite-processed assets

This is cribbed from the Lar­avel Vite inte­gra­tion docs:

There is cur­rent­ly an unsolved issue when ref­er­enc­ing assets in files processed by Vite, such as a Vue or CSS file. In devel­op­ment, URLs will not be prop­er­ly rewrit­ten.

Addi­tion­al­ly, there is cur­rent­ly no way to get the path of a Vite-processed asset (eg. an image that was import­ed in a Vue SFC) from the back-end, since the man­i­fest does not ref­er­ence the orig­i­nal file path. In most cas­es, this should not be an issue, as this is not a com­mon use case.

What you can do is lever­age the /public Pub­lic Direc­to­ry for sta­t­ic assets in Vite, so the URLs will not get rewritten.

The basic prob­lem is if you have a CSS rule like:

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

and your local dev runs off of some­thing like myhost.test then the image will be ref­er­enced as:


Which then resolves to:


When what you real­ly want is for it to be com­ing from the Vite dev server:


This is only a prob­lem when you’re using Vite with a back­end sys­tem like Craft CMS, where the host you run the web­site from is dif­fer­ent from where the Vite dev serv­er runs.

To work around this, as of Vite ^2.7.0 you can use the server.origin con­fig to tell Vite to serve the sta­t­ic assets it builds from the Vite dev serv­er, and not the web­site server:

  server: {
    fs: {
      strict: false
    host: '',
    origin: 'http://localhost:3000/',
    port: 3000,
    strictPort: true,

This issue was dis­cussed in detail, and fixed via a pull request that was rolled into Vite ^2.6.0 in the form of the ori­gin setting.

Link A peek at the manifest.json

Since a good bit of what the Vite plu­g­in does to make the mag­ic hap­pen is pars­ing the manifest.json file that is built for pro­duc­tion builds, let’s have a look at it.

You don’t need to know any of this to use Vite, it’s just for curiosity’s sake

Here’s an exam­ple manifest.json that has some dynam­ic imports, and uses the Lega­cy plu­g­in for mod­ern + lega­cy builds:

  "src/js/app.ts": {
    "file": "assets/app.56c9ea9d.js",
    "src": "src/js/app.ts",
    "isEntry": true,
    "imports": [
    "dynamicImports": [
    "css": [
  "_vendor.df0f627b.js": {
    "file": "assets/vendor.df0f627b.js"
  "src/vue/Confetti.vue": {
    "file": "assets/Confetti.d16ac27e.js",
    "src": "src/vue/Confetti.vue",
    "isDynamicEntry": true,
    "imports": [
  "src/js/app-legacy.ts": {
    "file": "assets/app-legacy.0c84e934.js",
    "src": "src/js/app-legacy.ts",
    "isEntry": true,
    "imports": [
    "dynamicImports": [
  "_vendor-legacy.87ce5c2f.js": {
    "file": "assets/vendor-legacy.87ce5c2f.js"
  "src/vue/Confetti-legacy.vue": {
    "file": "assets/Confetti-legacy.b15b2e8d.js",
    "src": "src/vue/Confetti-legacy.vue",
    "isDynamicEntry": true,
    "imports": [
  "vite/legacy-polyfills": {
    "file": "assets/polyfills-legacy.8fce4e35.js",
    "src": "vite/legacy-polyfills",
    "isEntry": true

This for­mat for the manifest.json is new as of Vite 2.x, and is quite a depar­ture of the typ­i­cal key/​value pair manifest.json gen­er­at­ed by your typ­i­cal web­pack build.

Each top-lev­el object rep­re­sents at least one JavaScript entry point, and lists any JavaScript that it imports, as well as any JavaScript that it dynam­i­cal­ly imports.

  • file — is the built JavaScript file
  • src — is the orig­i­nal source file used to build it

Then any CSS files that were extract­ed from the entry point is also listed.

The Vite 2.x man­i­fest is almost more of a depen­den­cy graph than any­thing else, which makes sense.

The Vite plu­g­in takes care of pars­ing all of the manifest.json to find the file you’re request­ing on the fron­tend, and ensure tags are gen­er­at­ed for it, and all of its depen­den­cies (JavaScript and CSS).

Link Try it yourself!

If you’d like to give Vite a spin with Craft CMS, we’ve made it super easy to do. There’s a Vite branch of the nystudio107/​craft boil­er­plate setup.

All you’ll need is Dock­er desk­top for your plat­form installed to get going! Just type the fol­low­ing in your terminal:

composer create-project nystudio107/craft:dev-craft-vite vitecraft --no-install --remove-vcs

Ensure no oth­er local devel­op­ment tools are run­ning that might be using ports 8000, 3306, & 3000, then to get the project up and run­ning, type:

cd vitecraft
make dev

The ini­tial start­up will take some time, because it’s build­ing all of the Dock­er images, doing an npm install, doing a composer install, etc.

Wait until you see the fol­low­ing to indi­cate that the PHP con­tain­er is ready:

php_1         | Craft is installed.
php_1         | Applying changes from your project config files ... done
php_1         | [01-May-2021 18:38:46] NOTICE: fpm is running, pid 22
php_1         | [01-May-2021 18:38:46] NOTICE: ready to handle connections

…and the fol­low­ing to indi­cate that the Vite con­tain­er is ready:

vite_1        |   vite v2.3.2 dev server running at:
vite_1        |
vite_1        |   > Local:    http://localhost:3000/
vite_1        |   > Network:
vite_1        |
vite_1        |   ready in 1573ms.

Then nav­i­gate to http://localhost:8000 to use the site; the Vite dev serv­er runs off of http://localhost:3000

If you view the brows­er Devel­op­er Con­sole, you should see the fol­low­ing to indi­cate that Vite is run­ning Hot Mod­ule Replace­ment (HMR):

[vite] connecting...
[vite] connected.

Then feel free to see the Vite HMR in action by edit­ing any of the JavaScript, Vue com­po­nents, CSS, or Twig files in the src/ directory!

Link Bundling it all up

As Evan You, the cre­ator of Vite.js (and Vue.js) said:

It is NOT Vite’s goal to com­plete­ly replace web­pack. There are prob­a­bly a small num­ber of features/​capabilities that some exist­ing web­pack projects rely on that does­n’t exist in Vite, but those fea­tures are in the long tail and are only need­ed by a small num­ber of pow­er users who write bespoke web­pack con­fig­u­ra­tion.

How­ev­er, in the con­text of the gen­er­al web dev pop­u­la­tion, 90% of exist­ing devs and 100% of begin­ners don’t need or care about these long tail fea­tures.

So — try it, and if it does­n’t work for you, stick to webpack.

I think Vite.js is com­pelling enough that it’s def­i­nite­ly worth giv­ing it a whirl. The speed and sim­plic­i­ty real­ly are remarkable.

If you’re a Craft CMS plu­g­in devel­op­er, check out the nys­tu­dio107/craft-plu­g­in-vite pack­age as a way to inte­grate a Vite.js build­chain into your plugins.

If you want to learn more, check out the Vite Awe­some page for more, well, awe­some Vite.js plu­g­ins, inte­gra­tions, and tutorials.

Hap­py bundling!