Andrew Welch · Insights · #plugin #vite #frontend

Published , updated · 5 min read ·

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

A Vite Buildchain for Craft CMS Plugins & Modules

A drop-in build­chain you can use in your Craft CMS plu­g­ins and mod­ules, giv­ing you Hot Mod­ule Replace­ment for JavaScript, Tail­wind CSS, and opti­mized pro­duc­tion builds.

A Vite Buildchain for Craft CMS Plugins

This arti­cle will show you how to drop a mod­ern Vite.js build sys­tem into your Craft CMS plu­g­in or mod­ule, and reap all of the ben­e­fits of the super fast Hot Mod­ule Replace­ment (HMR) development.

If you’re a Craft CMS plugin or module developer, this article is for you.

We saw how to set up a con­tainer­ized local devel­op­ment envi­ron­ment in the A Craft CMS Plu­g­in Local Devel­op­ment Envi­ron­ment arti­cle, and looked at the ben­e­fits of using Vite.js mod­ern fron­tend tool­ing in the Vite.js Next Gen­er­a­tion Fron­tend Tool­ing + Craft CMS article.

Now let’s bring them togeth­er for a great plu­g­in devel­op­ment DX inside of the Craft CMS CP.

Here’s a video show­ing you some of the ben­e­fits you’ll get:

This arti­cle assumes you have some knowl­edge of how to build Craft CMS plu­g­ins or modules.

If you don’t, you might want to check out the So You Wan­na Make a Craft 3 Plu­g­in? and the Enhanc­ing a Craft CMS 3 Web­site with a Cus­tom Mod­ule arti­cles first.

Link What the buildchain gives us

The Vite build­chain described in this arti­cle gives us the fol­low­ing features:

But don’t wor­ry, if you don’t use some of these tech­nolo­gies, you’re not oblig­at­ed to. They are sim­ply there if you need them.

A good question to ask is “why?”

The rea­son we want to use a build­chain at all is so that we can lever­age mod­ern tool­ing, work­flows, and frame­works for a deli­cious devel­op­er expe­ri­ence, while also gen­er­at­ing opti­mized pro­duc­tion builds that will work in any browser.

The DX from using Hot Mod­ule Replace­ment in your plu­g­ins or mod­ules alone is worth it, if there is any amount of JavaScript or CSS that goes into them.

So let’s dive right in and see what it takes to add a Vite.js build­chain to our Craft CMS plu­g­ins and modules.

N.B.: In the steps below, we’ll be using the Transcoder plu­g­in as our exam­ple; sub­sti­tute in any namespace or oth­er changes need­ed for your plugin.

Link Step 1: Require craft-plugin-vite package

When cre­at­ing the Vite plu­g­in that bridges Vite.js into your Craft CMS fron­tend Twig tem­plates, we decid­ed to make it a thin lay­er, and break out most of the func­tion­al­i­ty into the nys­tu­dio107/craft-plu­g­in-vite Com­pos­er package.

This was done so that fron­tend web­sites could take advan­tage of a Vite build­chain via the Vite plu­g­in, but also oth­er Craft CMS plu­g­ins or mod­ules could lever­age the exact same functionality.

So the first thing we’ll need to do is require the pack­age in our plugin:

composer require nystudio107/craft-plugin-vite

And indeed, you can see that the Vite plu­g­in itself requires this pack­age.

The craft-plugin-vite pro­vides us with two things we need to inte­grate a Vite.js build­chain into our plu­g­in or module:

  1. VitePluginService — This Yii2 com­po­nent (Ser­vice” in Craft 3 par­lance) pro­vides the func­tion­al­i­ty need­ed to con­nect to the Vite.js build sys­tem from a plugin
  2. ViteVariableInterface & ViteVariableTrait — these two com­bined allow us to include Vite.js gen­er­at­ed JavaScript & CSS from our Twig tem­plates from a plugin

Link Step 2: Set up the Vite service

In your plug­in’s pri­ma­ry class, you’ll need to pull in the VitePluginService:

use nystudio107\pluginvite\services\VitePluginService;

Next we’ll need to add this as a com­po­nent to our plug­in’s class. We can do this in a sim­ple way by lever­ag­ing the fact a con­struc­tor func­tion named __construct() is called when­ev­er an object is instantiated.

In the case of Yii2 Com­po­nent objects, the para­me­ters passed in are always an $id, $parent, and cru­cial­ly for us, a $config array.

We can lever­age Yii2’s use of Depen­den­cy Injec­tion (DI) Con­tain­ers to pass in our Vite ser­vice com­po­nent, and its configuration:

    // Static Methods
    // =========================================================================

     * @inheritdoc
    public function __construct($id, $parent = null, array $config = [])
        $config['components'] = [
            'transcode' => Transcode::class,
            // Register the vite service
            'vite' => [
                'class' => VitePluginService::class,
                'assetClass' => TranscoderAsset::class,
                'useDevServer' => true,
                'devServerPublic' => 'http://localhost:3001',
                'serverPublic' => 'http://localhost:8000',
                'errorEntry' => 'src/js/app.ts',
                'devServerInternal' => 'http://craft-transcoder-buildchain:3001',
                'checkDevServer' => true,

        parent::__construct($id, $parent, $config);

You can think of depen­den­cy injec­tion just as a fan­cy way of say­ing that a com­po­nent has its con­fig­u­ra­tion passed into it, so that depen­den­cies are only ever passed in, the com­po­nent nev­er reach­es out for configuration.

The con­fig­u­ra­tion array you pass in is then used to mass-set the class properties.

This allows com­po­nents to be swapped in and out eas­i­ly, and it’s in fact this very tech­nique that is used to con­fig­ure Craft CMS itself.

So we’re adding our components to the $config array, which are the Craft CMS ser­vices that are avail­able via Transcoder::$plugin->. We pass in our Transcode ser­vice, as well as the VitePluginService we import­ed from the craft-plugin-vite pack­age, with some configuration:

  • class — The class of the component
  • assetClass — the class of the Asset­Bun­dle that should be used for pub­lish­ing our assets on the fron­tend (more on this later)
  • useDevServer — whether the Vite plu­g­ins should attempt to use the Vite dev serv­er for HMR
  • devServerPublic — the URL for the dev serv­er, only Vite-processed assets (such as JavaScript, CSS, etc.) are served from this URL. Note that we’re serv­ing off of port 3001
  • serverPublic — the URL for the actu­al web serv­er that the site is being served from
  • errorEntry — the script that should be inject­ed on Twig error pages to allow for HMR through errors
  • devServerInternal — option­al, the inter­nal net­work name for the Vite dev serv­er, as access via PHP. This is used only if checkDevServer is true
  • checkDevServer — option­al, whether the Vite dev serv­er should be checked to see if it is run­ning, and if not, fall back on the pro­duc­tion build

In addi­tion to check­ing useDevServer, the VitePluginService will not attempt to use the Vite dev serv­er unless an envi­ron­ment vari­able named VITE_PLUGIN_DEVSERVER is present in your .env.

We do this because we want to ensure that the devel­op­ment serv­er is nev­er accessed by peo­ple who are using your plu­g­ins, but rather only when you are devel­op­ing them.

# Plugin debug

Link Step 3: Step up the Vite Variable

Next we need a way to access the VitePluginService from Twig. To do that, we’ll set up our plug­in’s vari­able like this:

namespace nystudio107\transcoder\variables;

use nystudio107\pluginvite\variables\ViteVariableInterface;
use nystudio107\pluginvite\variables\ViteVariableTrait;

class TranscoderVariable implements ViteVariableInterface
    use ViteVariableTrait;

You’ll notice that we have implements ViteVariableInterface next to our vari­able class here. Inter­faces in PHP are essen­tial­ly con­tracts, so that if you say a class imple­ments a giv­en inter­face, it will always imple­ment the meth­ods in the interface.

Here’s what ViteVariableInterface looks like, for example:

 * 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

namespace nystudio107\pluginvite\variables;

use yii\base\InvalidConfigException;

use Twig\Markup;

 * @author    nystudio107
 * @package   Vite
 * @since     1.0.4
interface ViteVariableInterface
    // Public Methods
    // =========================================================================

     * Return the appropriate tags to load the Vite script, either via the dev server or
     * extracting it from the manifest.json file
     * @param string $path
     * @param bool $asyncCss
     * @param array $scriptTagAttrs
     * @param array $cssTagAttrs
     * @return Markup
    public function script(string $path, bool $asyncCss = true, array $scriptTagAttrs = [], array $cssTagAttrs = []): Markup;

     * Register the appropriate tags to the Craft View to load the Vite script, either via the dev server or
     * extracting it from the manifest.json file
     * @param string $path
     * @param bool $asyncCss
     * @param array $scriptTagAttrs
     * @param array $cssTagAttrs
     * @return Markup
     * @throws InvalidConfigException
    public function register(string $path, bool $asyncCss = true, array $scriptTagAttrs = [], array $cssTagAttrs = []): Markup;

     * Inline 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
     * @param string $pathOrUrl
     * @return string|null
    public function inline(string $pathOrUrl): Markup;

These are the method dec­la­ra­tions that your vari­able class must imple­ment. For­tu­nate­ly for you, the imple­men­ta­tion work has already been done in the ViteVariableTrait.

A trait in PHP is a way for the lan­guage to sort of imple­ment mul­ti­ple inher­i­tance in its object mod­el. A giv­en class can use as many traits as it likes, which allows you to extend class­es in a piece­meal fashion.

An easy way to think of it is that when you have this in your class:

    use ViteVariableTrait;

…it is essen­tial­ly includ­ing all of the code inside of the trait {} def­i­n­i­tion in the file.

This will mag­i­cal­ly add the imple­men­ta­tion code for the ViteVariableInterface into our vari­able class.

Then we just need to reg­is­ter our vari­able in the pri­ma­ry plu­g­in class:

// Register our variables
    function (Event $event) {
        /** @var CraftVariable $variable */
        $variable = $event->sender;
        $variable->set('transcoder', [
            'class' => TranscoderVariable::class,
            'viteService' => $this->vite,

Note that we’re actu­al­ly using depen­den­cy injec­tion here too, because we’re pass­ing in the VitePluginService we set in our __construct() method either to our vari­able class.

We have to do this because there may be sev­er­al instances of plu­g­ins installed at the same time that all use this Vite build­chain set­up, and each needs their own sep­a­rate configuration.

Then we’ll be able to use the func­tions declared in the ViteVariableInterface in our Twig templates:

{{ craft.transcoder.script('src/js/app.ts') }}
{{ craft.transcoder.register('src/js/app.ts') }}
{{ craft.transcoder.inline('@webroot/icon.png') }}

Link Step 4: Set up the AssetBundle

Craft CMS uses Asset Bun­dles to group togeth­er and pub­lish assets used in the Con­trol Pan­el. Pub­lished asset bun­dles will appear in the web/cpresources/ in hashed direc­to­ry names, e.g.:

├── cpresources
│   ├── 2df31814
│   │   ├── jquery.js
│   │   └──
│   ├── 2e32674b
│   │   ├── selectize.css
│   │   ├── selectize.js
│   │   └──
│   ├── 3292780b
│   │   ├── velocity.js
│   │   └──
│   ├── 3330409c
│   │   ├── velocity.js
│   │   └──
│   ├── 3350fc86
│   │   ├── garnish.js
│   │   └──
│   └─── a9b1b7a4
│       ├──
│       └──
└── index.php

Since we’re using Vite to do the asset depen­den­cy, serv­ing, and bundling, we only need to use a skele­ton Asset­Bun­dle to give us the pub­lished URL to our built assets:

 * Transcoder plugin for Craft CMS 3.x
 * Transcode videos to various formats, and provide thumbnails of the video
 * @link
 * @copyright Copyright (c) 2017 nystudio107

namespace nystudio107\transcoder\assetbundles\transcoder;

use craft\web\AssetBundle;
use craft\web\assets\cp\CpAsset;

 * @author    nystudio107
 * @package   Transcode
 * @since     1.0.0
class TranscoderAsset extends AssetBundle
    // Public Methods
    // =========================================================================

     * @inheritdoc
    public function init()
       $this->sourcePath = '@nystudio107/transcoder/web/assets/dist';

        $this->depends = [

As you can see, we’re not actu­al­ly reg­is­ter­ing and JavaScript or CSS assets here, we’re just set­ting the sourcePath to where the Vite-built assets live in our plug­in’s direc­to­ry, and set­ting a bun­dle depen­den­cy on the Craft CpAs­set with depends.

So we’re just using the Asset Bun­dle to get a pub­lished path for our resources.

Link Step 5: Drop in the Vite Buildchain

Now that we have the plu­g­in scaf­fold­ing in place, let’s actu­al­ly drop in the Vite build­chain by cloning the repos­i­to­ry down:

git clone

Then copy the fol­low­ing into your plu­g­in or mod­ule’s root:

  • buildchain/ — this con­tains the Vite build­chain configuration
  • src/web/assets/ — this con­tains the fron­tend assets (JavaScript, CSS, images, etc)
  • .gitignore — to ensure direc­to­ries like node_modules/ are not checked into your repository

The build­chain uses Dock­er to work; but it is con­tainer­ized in such a way that you can use it with what­ev­er your local devel­op­ment set­up is (Dock­er or otherwise).

N.B.: If you won’t be using this build­chain with the plug­in­dev local devel­op­ment envi­ron­ment, you’ll need to delete this line from the Makefile:

--network plugindev_default \

…so that it won’t be look­ing for the plugindev_default Dock­er network.

Now just go to the buildchain/ direc­to­ry in Ter­mi­nal, and type:

make dev

…to fire up the Vite dev serv­er. Note that the Vite dev serv­er runs on port 3001. To stop it from run­ning, just hit Control-C.

To build the pro­duc­tion assets, type:

make build

…and it will build the pro­duc­tion assets to src/web/assets/dist/ which is the direc­to­ry that our Asset­Bun­dle publishes.

You can also run arbi­trary npm com­mands in the build­chain con­tain­er via make npm xxx , which runs the npm com­mand passed in, e.g.: make npm install

For more on using make in this man­ner, check out the Using Make & Make­files to Auto­mate your Fron­tend Work­flow article.

Now let’s have a look at the vite.config.js file:

import {defineConfig} from 'vite';
import vue from '@vitejs/plugin-vue'
import ViteRestart from 'vite-plugin-restart';
import viteCompression from 'vite-plugin-compression';
import manifestSRI from 'vite-plugin-manifest-sri';
import {visualizer} from 'rollup-plugin-visualizer';
import eslintPlugin from 'vite-plugin-eslint';
import {nodeResolve} from '@rollup/plugin-node-resolve';
import * as path from 'path';

export default defineConfig(({command}) => ({
  base: command === 'serve' ? '' : '/dist/',
  build: {
    emptyOutDir: true,
    manifest: true,
    outDir: '../src/web/assets/dist',
    rollupOptions: {
      input: {
        app: 'src/js/app.ts',
        welcome: 'src/js/welcome.ts',
      output: {
        sourcemap: true
  plugins: [
      moduleDirectories: [
      reload: [
      filter: /\.(js|mjs|json|css|map)$/i
      filename: '../src/web/assets/dist/stats.html',
      template: 'treemap',
      sourcemap: true,
      cache: false,
  publicDir: '../src/web/assets/public',
  resolve: {
    alias: {
      '@': path.resolve(__dirname, './src')
    preserveSymlinks: true,
  server: {
    fs: {
      strict: false
    host: '',
    origin: 'http://localhost:3001/',
    port: 3001,
    strictPort: true,

We won’t go over the vite.config.js file in detail here; check out the Vite.js Next Gen­er­a­tion Fron­tend Tool­ing + Craft CMS arti­cle or the Vite Doc­u­men­ta­tion for details on that.

Nor­mal­ly, Vite con­sid­ers the project root to be where every­thing is served from: your source code, node_modules npm pack­ages, the public/ direc­to­ry, and the built pro­duc­tion assets in dist/

Since we want to inte­grate the build­chain in our exist­ing plu­g­in or mod­ule direc­to­ry struc­ture, we move things around a bit:

  1. src/ — a sym­link is cre­at­ed in the buildchain/ direc­to­ry that points to ../src/web/assets/src to point to our source directory
  2. build.outDir — this is set to ../src/web/assets/dist to build our pro­duc­tion assets there
  3. publicDir — this is set to ../src/web/assets/public and is for any sta­t­ic assets that should should be copied unmod­i­fied into the build.outDir
  4. @rollup/plugin-node-resolve — this plu­g­in is used to help Vite fig­ure out where the node_modules direc­to­ry lives so imports work prop­er­ly (it seems to get con­fused by the src/ alias). I filed a FR to hope to make this cleaner.

The ViteR­estart plu­g­in is used to HMR changes to our plu­g­in or mod­ule’s Twig tem­plates… but you could also pass in a glob to your PHP files if you want­ed the brows­er to reload on changes to those, too!

Link Step 6: Add Vite to your Twig templates

Final­ly, we can add some Twig tags to our plug­in’s tem­plates to get our fron­tend assets to appear.

Nor­mal­ly you’d use {{ craft.transcoder.script('src/js/app.ts') }} to include that file, but that out­puts the script tags direct­ly into our templates.

What we real­ly want to do inside of the Craft CMS CP is reg­is­ter the script tags the way we would Asset Bun­dles, to allow for Asset Bun­dle dependencies:

    {% set scriptTagOptions = {
        'depends': [
    } %}
    {{ craft.transcoder.register('src/js/app.ts', false, scriptTagOptions) }}
    {{ craft.transcoder.register('src/js/welcome.ts', false, scriptTagOptions) }}

The false val­ue we pass in as the sec­ond para­me­ter tells it that the CSS should not be asynchronous.

The third para­me­ter is an array of attrib­ut­es for the script tag. The depends option ensures that our script won’t be loaded until the assets that depend on the Transcode­As­set are loaded.

This ends up call­ing Yii2’s registerJsFile() & registerCssFile() instead of out­putting the tags directly.

In this way, we’ve inte­grat­ed with Craft CMS by lever­ag­ing its Asset Bun­dle sys­tem, fus­ing it with the Vite.js buildchain.

N.B.: One cau­tion is not to use dynam­ic imports (e.g.: import() as a func­tion) in your JavaScript or com­po­nents that will be used in the CP, because they are resolved a bun­dle time and will fail with an error like:

7:44:44 PM [vite] Internal server error: ENOENT: no such file or directory, open '/app/buildchain/src/vue/Test.vue'

The same thing hap­pens with web­pack dynam­ic imports in the CP; so it’s not a lim­i­ta­tion of Vite. Stick to reg­u­lar old imports and you’ll be good as gold.

Link Lazy Lightning

Hope­ful­ly this write­up has helped you drop a build­chain into your Craft CMS plu­g­ins or mod­ules that will allow you to make some amaz­ing plugins.

Vite js craft cms plugin buildchain

Hav­ing a good DX can allow you to rich­er expe­ri­ences for the end user more quickly.

A lit­tle bit of set­up can pay off in a huge way as you spend months work­ing on a project.

Hap­py bundling!