Andrew Welch
Published , updated · 5 min read · RSS Feed
Please consider 🎗 sponsoring me 🎗 to keep writing articles like this.
Vite.js Next Generation Frontend Tooling + Craft CMS
Vite.js is next generation frontend tooling for modern JavaScript that combines speed with simplicity. This article shows you how to integrate Vite.js with Craft CMS
Vite.js is a fast & opinionated… but its opinions are quite good
Vite.js is designed to work with the JavaScript & Node.js ecosystems, but using the Vite plugin I wrote, we can use it for Craft CMS too.
Vite.js has fast startup times because it leverages ES Modules in modern browsers rather than bundling, and it uses esbuild under the hood for fast transpilation of TypeScript, JSX, and more.
If you want to try out a live, fully functioning website yourself that uses Vite.js, check out the Spoke & Chain GitHub repository. Compare & contrast this with the Europa Museum GitHub repostitory, which has the same architecture, but uses webpack 5 for the buildchain.
Link Why Vite.js?
The primary reasons to use Vite.js are speed and simplicity.
It’s fast to start up, the hot module replacement (HMR) is near-instantaneous, and the production build times are fast too.
It’s also quite simple to set up, there isn’t much configuration to be done, and using it involves just linking to the script you want to use.
Vite.js combines the simplicity of a script tag with the power of modern JavaScript
Instead of setting up an elaborate Babel pipeline, you just put a <script src="src/js/app.ts"> in your code, and like magic you can use TypeScript.
While it is tough to predict future trends, Vite.js also seems to be trending in the right direction relatively to the new crop of bundling tools.
Link Configuring the Vite plugin for Craft CMS
Vite.js is JavaScript frontend tooling, so by default it uses an index.html as an entrypoint to your application.
That doesn’t make sense in the context of a server-rendered setup like Craft CMS, but with the Vite plugin and some minor configuration changes, it works like a charm.
The first step is to install the plugin:
composer require nystudio107/craft-vite
You can also install the plugin via the Plugin 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/ directory as vite.php (so rename the file in the process).
Here’s what the file will look like:
<?php
/**
* Vite plugin for Craft CMS 3.x
*
* Allows the use of the Vite.js next generation frontend tooling with Craft CMS
*
* @link https://nystudio107.com
* @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 depending on what your local development environment looks like.
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). This defaults to using the DEV_MODE environment variable, so it’ll be on when in devMode, and off when not.
- 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
- 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 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?
See the Vite documentation if you need more help setting 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 something 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 packages you’ll need to set up a bare-bones Vite.js setup that works with Tailwind CSS. If you want to use Vue.js or React or Svelte or some other frontend framework, you’ll just add those packages and the appropriate Vite.js plugins.
Then do an npm install to install your packages, and then generate default tailwind.config.js and postcss.config.js files with:
npx tailwindcss init -p
Next we’ll create a vite.config.js file for our Vite.js configuration:
import legacy from '@vitejs/plugin-legacy'
import ViteRestart from 'vite-plugin-restart';
// https://vitejs.dev/config/
export default ({ command }) => ({
base: command === 'serve' ? '' : '/dist/',
build: {
emptyOutDir: true,
manifest: true,
outDir: './web/dist/',
rollupOptions: {
input: {
app: './src/js/app.ts',
}
},
},
plugins: [
legacy({
targets: ['defaults', 'not IE 11']
}),
ViteRestart({
reload: [
'./templates/**/*',
],
}),
],
server: {
host: '0.0.0.0',
},
});
The @vitejs/plugin-legacy plugin generates a legacy bundle for non-ESM native browsers, and the Vite plugin takes care of generating the appropriate module/nomodule tags for you.
The vite-plugin-restart plugin allows us to get live reload for our Twig templates or other files, in addition to the out of the box HMR for JavaScript and CSS. Just pass in as many path globs as you like.
Other settings of note:
- 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.emptyOutDir — always delete everything from the outDir before building
- 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). The leading ./ is important here, as absolute paths seem to break CommonJS imports
- server.host — this is set to 0.0.0.0 so that it broadcasts to all IPv4 addresses on the local machine, needed as of Vite 2.3.0 or later for Docker and VM-based setups
Then to start up the Vite.js dev server, you just do:
npm run dev
Your JavaScript files will be served up from http://localhost:3000 while the dev server is running.
When it’s time to do a production build, you just do:
npm run build
And your production assets will be built for your via Rollup, ready to deploy.
For more information on Vite.js itself, check out the excellent Vite.js documentation.
Link Using Vite.js with Craft CMS
Create your JavaScript, TypeScript, Vue single file components, etc. as you normally would. Let’s say we store them in the directory /src/js/
Here’s an example TypeScript file (remember, it could be JavaScript too):
import '/src/css/app.pcss';
console.log("Hello, world!");
Any CSS that we want processed via PostCSS needs to be imported via JavaScript. We don’t include the CSS in our templates via <link rel="stylesheet"> tags, the plugin does it for us.
Side note: as of Vite 2.3.0, the vite/dynamic-import-polyfill is no longer required to be imported.
That’s it. Then in our Twig templates, we just need to reference the file via a Twig function provided by the Vite plugin:
{{ craft.vite.script("src/js/app.ts") }}
The paths to your scripts are always relative to the project root, which by default is the same directory as the vite.config.js
In development with the Vite.js dev server running, this will generate a tag that looks like this:
<script type="module" src="http://localhost:3000/src/js/app.ts"></script>
The CSS will be injected automatically into the DOM via inline <style> tags while the dev server is running.
In production without the Legacy plugin, the Twig function will generate the following tags:
<script type="module" src="https://example.com/dist/assets/app.56c9ea9d.js" crossorigin></script>
<link href="https://example.com/dist/assets/app.c30f6458.css" rel="stylesheet" media="print" onload="this.media='all'">
Remember that any <script type="module"> scripts are downloaded without blocking the HTML parser, and executed in order after the DOM is parsed, they are non-blocking.
Here’s an image from the Google JavaScript Modules primer on how scripts load:
Since we’re using the Legacy plugin for dual modern + legacy builds, we’ll get even more tags generated automatically:
<script>
!function(){var e=document,t=e.createElement("script");if(!("noModule"in t)&&"onbeforeload"in t){var n=!1;e.addEventListener("beforeload",function(e){if(e.target===t)n=!0;else if(!e.target.hasAttribute("nomodule")||!n)return;e.preventDefault()},!0),t.type="module",t.src=".",e.head.appendChild(t),t.remove()}}();
</script>
<script type="nomodule" src="https://example.com/dist/assets/polyfills-legacy.8fce4e35.js"></script>
<script type="module" src="https://example.com/dist/assets/app.56c9ea9d.js" crossorigin></script>
<link href="https://example.com/dist/assets/app.c30f6458.css" rel="stylesheet" media="print" onload="this.media='all'">
<script type="nomodule" src="https://example.com/dist/assets/app-legacy.0c84e934.js"></script>
This 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, loaded asynchronously by default
- Legacy app.js script for legacy browsers
This is the module/nomodule pattern that’s been in use for years to allow for modern and legacy JavaScript bundles.
Modules that are dynamically loaded via one of your entry scripts will appear in the browser as <link rel="modulepreload"> — check out the Preloading Modules for information on that.
Link Live Reload through Twig Errors
The Vite plugin includes support for live reloading through Twig error pages. You just need to set the errorEntry setting in the config/vite.php file, e.g.:
'errorEntry' => 'src/js/app.ts',
Then the page will auto-reload whenever you fix the error!
Link 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 (eg. 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:
/src/img/woof.jpg
Which then resolves to:
http://myhost.test/src/img/woof.jpg
When what you really want is for it to be coming from the Vite dev server:
http://localhost:3000/src/img/woof.jpg
This is only a problem when you’re using Vite with a backend system like Craft CMS, where the host you run the website from is different from where the Vite dev server runs.
To work around this, as of Vite ^2.7.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 website server:
server: {
fs: {
strict: false
},
host: '0.0.0.0',
origin: 'http://localhost:3000/',
port: 3000,
strictPort: true,
}
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.
Link A peek at the manifest.json
Since a good bit of what the Vite plugin does to make the magic happen is parsing the manifest.json file that is built for production 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 example manifest.json that has some dynamic imports, and uses the Legacy plugin for modern + legacy builds:
{
"src/js/app.ts": {
"file": "assets/app.56c9ea9d.js",
"src": "src/js/app.ts",
"isEntry": true,
"imports": [
"_vendor.df0f627b.js"
],
"dynamicImports": [
"src/vue/Confetti.vue"
],
"css": [
"assets/app.c30f6458.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": [
"_vendor.df0f627b.js"
]
},
"src/js/app-legacy.ts": {
"file": "assets/app-legacy.0c84e934.js",
"src": "src/js/app-legacy.ts",
"isEntry": true,
"imports": [
"_vendor-legacy.87ce5c2f.js"
],
"dynamicImports": [
"src/vue/Confetti-legacy.vue"
]
},
"_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": [
"_vendor-legacy.87ce5c2f.js"
]
},
"vite/legacy-polyfills": {
"file": "assets/polyfills-legacy.8fce4e35.js",
"src": "vite/legacy-polyfills",
"isEntry": true
}
}
This format for the manifest.json is new as of Vite 2.x, and is quite a departure of the typical key/value pair manifest.json generated by your typical webpack build.
Each top-level object represents at least one JavaScript entry point, and lists any JavaScript that it imports, as well as any JavaScript that it dynamically imports.
- file — is the built JavaScript file
- src — is the original source file used to build it
Then any CSS files that were extracted from the entry point is also listed.
The Vite 2.x manifest is almost more of a dependency graph than anything else, which makes sense.
The Vite plugin takes care of parsing all of the manifest.json to find the file you’re requesting on the frontend, and ensure tags are generated for it, and all of its dependencies (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 boilerplate setup.
All you’ll need is Docker desktop for your platform installed to get going! Just type the following in your terminal:
composer create-project nystudio107/craft:dev-craft-vite vitecraft --no-install --remove-vcs
Ensure no other local development tools are running that might be using ports 8000, 3306, & 3000, then to get the project up and running, type:
cd vitecraft
make dev
The initial startup will take some time, because it’s building all of the Docker images, doing an npm install, doing a composer install, etc.
Wait until you see the following to indicate that the PHP container 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 following to indicate that the Vite container is ready:
vite_1 | vite v2.3.2 dev server running at:
vite_1 |
vite_1 | > Local: http://localhost:3000/
vite_1 | > Network: http://172.22.0.5:3000/
vite_1 |
vite_1 | ready in 1573ms.
Then navigate to http://localhost:8000 to use the site; the Vite dev server runs off of http://localhost:3000
If you view the browser Developer Console, you should see the following to indicate that Vite is running Hot Module Replacement (HMR):
[vite] connecting...
[vite] connected.
Then feel free to see the Vite HMR in action by editing any of the JavaScript, Vue components, CSS, or Twig files in the src/ directory!
Link Bundling it all up
As Evan You, the creator of Vite.js (and Vue.js) said:
It is NOT Vite’s goal to completely replace webpack. There are probably a small number of features/capabilities that some existing webpack projects rely on that doesn’t exist in Vite, but those features are in the long tail and are only needed by a small number of power users who write bespoke webpack configuration.
…
However, in the context of the general web dev population, 90% of existing devs and 100% of beginners don’t need or care about these long tail features.
…
So — try it, and if it doesn’t work for you, stick to webpack.
I think Vite.js is compelling enough that it’s definitely worth giving it a whirl. The speed and simplicity really are remarkable.
If you’re a Craft CMS plugin developer, check out the nystudio107/craft-plugin-vite package as a way to integrate a Vite.js buildchain into your plugins.
If you want to learn more, check out the Vite Awesome page for more, well, awesome Vite.js plugins, integrations, and tutorials.
Happy bundling!