Back to blog

Migrating a Laravel Jetstream App from Webpack to Vite

·6 min read
LaravelVueVite

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

A huge shoutout to Matt for helping me with this. We worked together on a call to migrate my project Analyse. If you're not already, give Matt a follow - he shares awesome Laravel tips and his progress on a great 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 - a massive difference when pushing to production often.

Requirements

This is an opinionated guide using:

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

Migration

First, install the dependencies:

npm install vite @vitejs/plugin-vue

Then update your package.json:

package.json
{
   "scripts": {
       "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 making these changes, rename your tailwindcss.config.js file to tailwind.cjs.

Next, create a new vite.client.config.js file in your project root:

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 replace the following tags in your blade file:

<head>
    @production
    @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>

After making these changes, update your bootstrap file to use imports:

import _ from "lodash";
import axios from "axios";
 
window._ = _;
window.axios = axios;
 
window.axios.defaults.headers.common["X-Requested-With"] = "XMLHttpRequest";

Then modify your app.js file:

import "../css/app.css";
 
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 = () => {
    return import.meta.glob("./Pages/**/*.vue");
};
 
createInertiaApp({
    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";

What we're doing here:

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

The final and most important change is ensuring each component import has a .vue suffix:

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

Without this change, your components or layout will not load.

Finally, delete remaining Webpack files and dependencies:

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, you'll need SSR. Inertia.js makes this fairly easy with a built-in server - we just need to configure Vite in SSR mode.

First, install the following packages:

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

Create an SSR vite file for serving assets from the server:

vite.ssr.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: {
        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()],
}));

Create an ssr.js file in resources/js:

resources/js/ssr.js
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;
        },
    })
);

Create a useRoute.ts file in resources/js/Composable for Ziggy support:

resources/js/Composable/useRoute.ts
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);
 
    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 shoutout to Bruno Tomé for building and sharing this code snippet.

Now configure your Inertia server. Add the following to your app.blade.php file:

app.blade.php
<!DOCTYPE html>
<html lang="{{ str_replace('_', '-', app()->getLocale()) }}">
    <head>
        @routes
        @inertiaHead
    </head>
    <body class="font-sans antialiased">
        @inertia
    </body>
</html>

Add SSR scripts to package.json:

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

Publish your inertia.php file:

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

Enable SSR in config/inertia.php:

[
    'ssr' => [
        'enabled' => true,
        'url' => 'http://127.0.0.1:13714/render',
    ],
]

For local testing, run in separate windows:

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

For production:

npm run production
npm run production-ssr

If you don't have PM2, install it:

npm install pm2@latest -g

Start the SSR server:

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 moved from Webpack to Vite and should see a massive speed difference.