Gamertag.comOwn one of the most iconic gaming domains 

Migrating a Laravel Jetstream app from Mix/Webpack to Vite

February 23, 2022 (2y ago)

For months I've been frustrated with how slow Laravel Mix (Webpack) was to compile, even on a beefy M1 Max or Ryzen machine, yet current guides for migrating to Vite were unhelpful to me. Every time I'd migrate, get stuck, revert back, and repeat the process.

Before I begin, I want to give a huge shoutout to Matt for sparing some time to help me with this, we worked together on a call this morning to migrate my newest project Analyse. If you're not already, go give Matt a follow, he shares really awesome Laravel tips - in addition to sharing his progress on a sweet new developer platform.

Speed Comparison

Locally on an M1 Max, I went from ~3 seconds with Mix down to 120 milliseconds with Vite during development. On our Ryzen production machine, this came down from 10 seconds to 5 seconds, such a crazy difference when pushing to production often.

Requirements

I want to mention that this is an opinionated guide, by this I mean that the following are used:

  • Vue 3
  • Inertia.js
  • Laravel Jetstream
  • TailwindCSS v3
  • Inertia Global/Persistant Layouts

I can't comment on other set-ups, but this is what we are using within Analyse, and wanted to share in case it helps others.

Migration

First head to package.json, as you'll need to run the following to install the dependencies needed:

npm install vite @vitejs/plugin-vue

Then, you'll need to add the following:

package.json
{
   "scripts": {
       "dev": "npm run development", // [!shiki -:0,7]
       "development": "mix",
       "watch": "mix watch",
       "watch-poll": "mix watch -- --watch-options-poll=1000",
       "hot": "mix watch --hot",
       "prod": "npm run production",
       "production": "mix --production",
       "dev": "vite --config vite.client.config.js",
       "watch": "vite --config vite.client.config.js",
       "prod": "npm run production",
       "production": "vite build --config vite.client.config.js"
    },
 
   "postcss": { 
       "plugins": {
           "autoprefixer": {},
           "tailwindcss": {
               "config": "tailwind.cjs"
           }
       }
   }
}

After you've made these changes, rename your tailwindcss.config.js file to tailwind.cjs.

Next, you'll need to create a new vite.client.config.js file within the root of your project:

vite.client.config.js
import { defineConfig } from "vite";
import { resolve } from "path";
import vue from "@vitejs/plugin-vue";
 
export default defineConfig(({ command }) => ({
    base: command === "serve" ? process.env.ASSET_URL || "" : `${process.env.ASSET_URL || ""}/build/`,
    publicDir: false,
    build: {
        manifest: true,
        outDir: "public/build",
        rollupOptions: {
            input: "resources/js/app.js",
        },
    },
    resolve: {
        alias: {
            "@": resolve(__dirname, "resources/js"),
            "/img": resolve(__dirname, "public/img"),
        },
    },
    plugins: [vue()],
    server: { fs: { allow: [`${process.cwd()}`] }, port: process.env?.VITE_PORT ?? 3000 },
}));
 

Then you'll want to replace the following tags:

<head>
    <link href="{{ mix('/css/app.css') }}" rel="stylesheet" /> <!-- [!shiki -:0,2] -->
    <script src="{{ mix('/js/app.js') }}" defer></script>
    
    @production <!-- [!shiki +:0,12] -->
    @php
        $manifest = json_decode(File::get(public_path('build/manifest.json')), true);
    @endphp
    <script type="module" src="{{ asset('build/' . $manifest['resources/js/app.js']['file']) }}"></script>
    <link rel="stylesheet" href="{{ asset('build/' . $manifest['resources/js/app.js']['css'][0]) }}">
    @else
        @verbatim
            <script type="module" src="http://localhost:3000/@vite/client"></script>
        @endverbatim
        <script type="module" src="http://localhost:3000/resources/js/app.js"></script>
    @endproduction
</head>

The new change tells Laravel where our assets will be, and for development we'll be using our live server instead.

After you've made these changes, we will need to change our bootstrap file to use imports instead:

window._ = require('lodash'); // [!shiki -]
import _ from "lodash"; // [!shiki +:0,1]
import axios from "axios";
 
window._ = _; // [!shiki +]
 
window.axios = require('axios'); // [!shiki - +:0,1]
window.axios = axios;
 
window.axios.defaults.headers.common["X-Requested-With"] = "XMLHttpRequest";

Then head to your app.js file and modify your file to look similar to this:

require('./bootstrap') // [!shiki -]
import "../css/app.css"; // [!shiki +]
 
import { createApp, h } from "vue";
import { createInertiaApp, Head, Link } from "@inertiajs/inertia-vue3";
import AppLayout from "@/Layouts/AppLayout.vue";
import { ZiggyVue } from "../../vendor/tightenco/ziggy/dist/vue.es";
 
let asyncViews = () => { // [!shiki +:0,2]
    return import.meta.glob("./Pages/**/*.vue");
};
 
createInertiaApp({ // [!shiki +:0,22]
    title: (title) => (title != "Home" ? `${title} - JoinServers` : "The Best Minecraft Server List Website - JoinServers"),
    resolve: async (name) => {
        if (import.meta.env.DEV) {
            let page = (await import(`./Pages/${name}.vue`)).default;
            return page;
        } else {
            let pages = asyncViews();
            const importPage = pages[`./Pages/${name}.vue`];
 
            return importPage().then((module) => module.default);
        }
    },
    setup({ el, App, props, plugin }) {
        const VueApp = createApp({ render: () => h(App, props) });
 
        VueApp.config.globalProperties.route = window.route;
 
        VueApp.use(plugin).use(ZiggyVue).component("InertiaHead", Head).component("InertiaLink", Link).mount(el);
    },
});
 
 
import "./bootstrap"; // [!shiki +]

So what are we doing here?

  • Globally define the Inertia Head and Link (optional)
  • Added the asyncViews variable.
  • Changing all require to use import.
  • Adding our globalProperties for Ziggy.
  • Importing our stylesheet.

The final and most important change left is to ensure each component imported has a .vue suffix, for example:

import JetTable from "@/Jetstream/Table";
import JetTable from "@/Jetstream/Table.vue";

Without making this change, your components or layout will not load, and I spent ages debugging being unsure why.

Finally, it's time to delete any remaining webpack files and dependencies, like so:

rm -rf webpack.config.js
rm -rf webpack.mix.js
npm remove laravel-mix

Server-Side Rendering (SSR) Support

If search traffic is your main source of visitors, then you'll need SSR to be able to load the first page from the server. Thankfully, Inertia.js makes this fairly easy by providing us with a built-in server, we just need to configure Vite in SSR mode.

To do this, we first need to install the following packages:

npm install @vue/server-renderer @inertiajs/server

Once you have installed these, we need to create our SSR vite file for serving assets from our server:

import { defineConfig } from "vite";
import { resolve } from "path";
import vue from "@vitejs/plugin-vue";
 
export default defineConfig(({ command }) => ({
    base: command === "serve" ? process.env.ASSET_URL || "" : `${process.env.ASSET_URL || ""}/build/`,
    publicDir: false,
    build: {
        ssr: true,
        target: "node17",
        outDir: "public/build-ssr",
        rollupOptions: {
            input: "resources/js/ssr.js",
        },
    },
    resolve: {
        alias: {
            "@": resolve(__dirname, "resources/js"),
            "/img": resolve(__dirname, "public/img"),
        },
    },
    plugins: [vue()],
}));
 

As you can see, this file is very similar to our vite.client.config.js file, except we are optimising this to be ran off a server instead.

Next we need to create an ssr.js file within the resources/js folder for compiling our assets:

import { createSSRApp, h } from "vue";
import { renderToString } from "@vue/server-renderer";
import { createInertiaApp, Head, Link } from "@inertiajs/inertia-vue3";
import createServer from "@inertiajs/server";
import useRoute from "./Composable/useRoute";
 
let asyncViews = () => {
    return import.meta.glob("./Pages/**/*.vue");
};
 
createServer((page) =>
    createInertiaApp({
        title: (title) => (title != "Home" ? `${title} - CharlieJoseph` : "An Example Default Title - CharlieJoseph"),
        page,
        render: renderToString,
        resolve: (name) => {
            let pages = asyncViews();
            const importPage = pages[`./Pages/${name}.vue`];
            return importPage().then((module) => module.default);
        },
        setup({ app, props, plugin }) {
            const VueApp = createSSRApp({ render: () => h(app, props) });
 
            VueApp.config.globalProperties.route = useRoute;
 
            VueApp.use(plugin).component("InertiaHead", Head).component("InertiaLink", Link);
 
            return VueApp;
        },
    })
);

After you've created this file, we will need to enable support for utilising Ziggy within our SSR environment. To this, create a useRoute.ts file within the resources/js/Composable folder with the following content:

import { computed } from "vue";
import { usePage } from "@inertiajs/inertia-vue3";
import baseRoute, { Config, RouteParamsWithQueryOverload } from "ziggy-js";
 
const locale = computed(() => usePage().props.value.locale);
const isServer = typeof window === "undefined";
 
let route;
 
if (isServer) {
    const ziggy = computed(() => usePage().props.value.ziggy);
 
    // @ts-ignore
    const ZiggyConfig = computed<Config>(() => ({
        ...ziggy.value,
        location: new URL(ziggy.value.url),
    }));
 
    route = (name, params, absolute = false, config = ZiggyConfig.value) => baseRoute(name, params, absolute, config);
} else {
    route = baseRoute;
}
 
export const localizedRoute = (routeName: string, params?: RouteParamsWithQueryOverload) => {
    if (locale.value === "en") {
        return route(routeName, params);
    }
 
    return route(`${locale.value}.${routeName}`, params);
};
 
export default route;

A huge shout-out to Bruno Tomé for building and sharing this code snippet - it is a huge factor in making Ziggy with SSR possible.

Now we need to configure our Inertia server, to do that we first need to add the following to our app.blade.php file:

<!DOCTYPE html>
<html lang="{{ str_replace('_', '-', app()->getLocale()) }}">
    <head>
        <!-- Other code ^ -->
        @routes
        @inertiaHead <!-- [!shiki +] -->
    </head>
    <body class="font-sans antialiased">
        @inertia
    </body>
</html>

Then head to your package.json file, as we need to add our SSR scripts:

{
    "scripts": {
        "dev": "vite --config vite.client.js",
        "ssr-dev": "vite --config vite.ssr.config.js", // [!shiki +]
        "watch": "vite --config vite.client.js",
        "production": "vite build --config vite.client.js",
        "ssr-production": "vite build --config vite.ssr.config.js", // [!shiki +]
        "start-ssr": "node public/build-ssr/ssr.js" // [!shiki +]
    },

Now we need to configure our Inertia SSR server, so lets publish our inertia.php file:

php artisan vendor:publish --provider="Inertia\ServiceProvider"

This inertia.php file will be created in the config/ directory, we need to enable SSR like so:

[
    'ssr' => [
        'enabled' => false, // # [!shiki - +:1,1]
        'enabled' => true,
        'url' => 'http://127.0.0.1:13714/render',
    ],
]

Now, when testing locally you can run the following in separate windows:

  1. npm run dev
  2. npm run ssr-dev
  3. npm run start-ssr

When you're ready to go to production, you'd run the following on your production machine:

npm run production
npm run production-ssr

If you don't already have PM2, install it by doing the following:

npm install pm2@latest -g

This allows us to keep our SSR server alive, which we can start by running:

pm2 start public/build-ssr/ssr.js

That's it! You now have SSR running for your Laravel + Inertia + Vue + Vite application ☺️


You're done! You've now moved from Webpack to Vite, and should see a massive speed difference. 🎉