Vite and Frontend Assets

This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production.

The reference material consulted while preparing these pages is listed in the section bibliography.

This page is written against Laravel 13.x (13.34.0) on PHP 8.3 to 8.5, with Vite 8.3, laravel-vite-plugin 3.2 and Tailwind CSS 4.3, checked on 2026-10-05. Every example is built and served in the Bookshelf application used across this guide. It covers how Laravel and Vite fit together; the tools themselves are explained on their own pages: Tailwind CSS, React, Vue, JavaScript and TypeScript.

The configuration of a new application

A new application ships package.json (Vite, the Laravel plugin, Tailwind 4 and its Vite plugin), vite.config.js, resources/css/app.css and resources/js/app.js. Bookshelf’s configuration, after two changes explained below:

// vite.config.js
import { defineConfig } from 'vite';
import laravel from 'laravel-vite-plugin';
import { fontsource } from 'laravel-vite-plugin/fonts';
import tailwindcss from '@tailwindcss/vite';

export default defineConfig({
    plugins: [
        laravel({
            input: ['resources/css/app.css', 'resources/js/app.js'],
            assets: ['resources/images/**'],             // files referenced only from Blade
            refresh: true,
            fonts: [
                fontsource('Instrument Sans', {          // from node_modules, no network at build time
                    weights: [400, 500, 600],
                }),
            ],
        }),
        tailwindcss(),
    ],
    server: {
        watch: {
            ignored: ['**/storage/framework/views/**'],
        },
    },
});
/* resources/css/app.css, as generated */
@import 'tailwindcss';

@source '../../vendor/laravel/framework/src/Illuminate/Pagination/resources/views/*.blade.php';
@source '../../storage/framework/views/*.php';

@theme {
    --font-sans: 'Instrument Sans', ui-sans-serif, system-ui, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji',
        'Segoe UI Symbol', 'Noto Color Emoji';
}

input lists the entry points. refresh: true reloads the browser when a Blade view, route file or translation changes. The two @source lines tell Tailwind 4 to scan the pagination views and compiled Blade files for class names, since Tailwind 4 has no tailwind.config.js content list; Tailwind’s own configuration is on Tailwind Customization and Configuration.

The generated configuration used bunny('Instrument Sans', ...), which downloads the font from fonts.bunny.net during npm run build. In a build environment without access to that host the build failed in resolveRemoteFont with "0 modules transformed". fontsource() reads the same font from the @fontsource/instrument-sans npm package instead (npm install -D @fontsource/instrument-sans), so the build needs only the npm registry. local() serves fonts committed to resources/fonts.

@vite in production and in development

npm run build writes hashed files and public/build/manifest.json; @vite([...]) reads the manifest and emits the tags:

$ npm run build
vite v8.3.2 building client environment for production...
public/build/manifest.json                                      1.47 kB │ gzip:  0.33 kB
public/build/fonts-manifest.json                                5.74 kB │ gzip:  0.71 kB
public/build/assets/instrument-sans-400-normal-DRC__1Mx.woff2  16.86 kB
...
public/build/assets/cover-D5pAZEeb.png                          7.02 kB
public/build/assets/app-hF-j6T4A.css                           55.14 kB │ gzip: 11.74 kB
public/build/assets/app-B2JTsE0B.js                             0.10 kB │ gzip:  0.11 kB
✓ built in 405ms
{{-- resources/views/shelf/assets.blade.php --}}
<!doctype html>
<html lang="en">
<head>
    <meta charset="utf-8">
    @fonts
    @vite(['resources/css/app.css', 'resources/js/app.js'])
</head>
<body class="font-sans">
    <img src="{{ Vite::asset('resources/images/cover.png') }}" alt="A sample cover">
    <div data-shelf class="p-4 text-lg">Shelf</div>
</body>
</html>
<!-- production: tags from the manifest (one line in the response, split here) -->
<link rel="preload" as="font" href="http://localhost:8000/build/assets/instrument-sans-400-normal-DRC__1Mx.woff2" type="font/woff2" crossorigin="anonymous" />
<style>@font-face { font-family: "Instrument Sans"; ... }</style>
<link rel="preload" as="style" href="http://localhost:8000/build/assets/app-hF-j6T4A.css" />
<link rel="modulepreload" as="script" href="http://localhost:8000/build/assets/app-B2JTsE0B.js" />
<link rel="stylesheet" href="http://localhost:8000/build/assets/app-hF-j6T4A.css" />
<script type="module" src="http://localhost:8000/build/assets/app-B2JTsE0B.js"></script>
<img src="http://localhost:8000/build/assets/cover-D5pAZEeb.png" alt="A sample cover">

npm run dev (or composer run dev, which also starts the PHP server, the queue listener and Pail) starts the Vite dev server and writes its URL to public/hot. While that file exists, @vite points at the dev server instead of the manifest, and the browser receives modules on demand with hot module replacement:

$ npx vite --port 5173
  VITE v8.3.2  ready in 449 ms
  ➜  Local:   http://localhost:5173/
[laravel:fonts] Optimized font fallbacks require the optional "fontaine" package. Install it, or set "optimizedFallbacks: false" on your fonts to disable the feature.
  LARAVEL v13.34.0  plugin v3.2.0
  ➜  APP_URL: http://localhost:8000
$ cat public/hot
http://127.0.0.1:5173
$ curl -s localhost:8000/shelf/assets | grep -o '<script[^>]*></script>\|<link rel="stylesheet"[^>]*>\|<img[^>]*>'
<script type="module" src="http://127.0.0.1:5173/@vite/client"></script>
<link rel="stylesheet" href="http://127.0.0.1:5173/resources/css/app.css" />
<script type="module" src="http://127.0.0.1:5173/resources/js/app.js"></script>
<img src="http://127.0.0.1:5173/resources/images/cover.png" alt="A sample cover">

Stopping the dev server deletes public/hot, and the same page served the built files again. A stale public/hot left by a crashed dev server, or one deployed by mistake, makes production pages load scripts from 127.0.0.1:5173 and render unstyled; public/hot belongs in .gitignore and nowhere near a deployment artifact. Without either a manifest or a hot file, @vite throws ViteManifestNotFoundException, which is why feature tests call $this->withoutVite() when the assets have not been built.

Static assets referenced from Blade

Vite only versions files it knows about. Files used only in Blade, such as an image referenced with Vite::asset(), are listed in the plugin’s assets option (laravel-vite-plugin 3). Bookshelf first used the import.meta.glob(['../images/**']) line from older tutorials in app.js; under Vite 8 the build emitted no images, and the page failed with ViteException: Unable to locate file in Vite manifest: resources/images/cover.png. The plugin’s upgrade guide states the change: from 2.x to 3.x, import.meta.glob for static assets is replaced by assets: ['resources/images/**']. With that option the build listed cover-D5pAZEeb.png and Vite::asset() returned its hashed URL.

JavaScript frameworks, environment variables and SSR

React, Vue and Svelte are added as Vite plugins (@vitejs/plugin-react, @vitejs/plugin-vue, @sveltejs/vite-plugin-svelte) with an entry point such as resources/js/app.jsx; the starter kits configure this, plus TypeScript and Inertia, out of the box (Inertia and Starter Kits). Vue needs the plugin’s transformAssetUrls option so that <img src="/images/..."> in single-file components is not rewritten as a module import.

Only variables prefixed with VITE_ in .env reach the browser bundle, through import.meta.env:

# .env
VITE_APP_NAME="${APP_NAME}"

# the dev server's version of resources/js/app.js
$ curl -s localhost:5173/resources/js/app.js | head -1
import.meta.env = {"BASE_URL": "/", "DEV": true, "MODE": "development", "PROD": false, "SSR": false, "VITE_APP_NAME": "Laravel"};const shelf = document.querySelector('[data-shelf]');

Anything with the prefix is public: never prefix a secret. The value is baked into the bundle at build time, so changing .env on the server has no effect until the assets are rebuilt.

Server-side rendering of a React, Vue or Svelte front end is enabled with ssr: 'resources/js/ssr.js' in the plugin options, built with vite build && vite build --ssr, and served by a Node process (php artisan inertia:start-ssr for Inertia).

CSP nonces, SRI and prefetching

A strict Content Security Policy blocks inline and unknown scripts. Vite::useCspNonce() generates a nonce for the request and adds it to every tag @vite and @fonts produce, so the policy can allow exactly those:

<?php
// routes/catalogue.php
use Illuminate\Support\Facades\Route;
use Illuminate\Support\Facades\Vite;

Route::get('/shelf/assets-csp', function () {
    $nonce = Vite::useCspNonce();               // random per request
    Vite::prefetch(concurrency: 3);

    return response()->view('shelf.assets')
        ->header('Content-Security-Policy', "script-src 'nonce-{$nonce}'; style-src 'self' 'nonce-{$nonce}'");
});
$ curl -s -D - localhost:8000/shelf/assets-csp | grep -o "Content-Security-Policy: .*\|<script[^>]*>\|<link rel=\"stylesheet\"[^>]*>\|<style[^>]*>"
Content-Security-Policy: script-src 'nonce-…'; style-src 'self' 'nonce-…'
<style nonce="…">
<link rel="stylesheet" href="http://localhost:8000/build/assets/app-hF-j6T4A.css" nonce="…" />
<script type="module" src="http://localhost:8000/build/assets/app-B2JTsE0B.js" nonce="…">
<script nonce="…">

The nonce is 40 random characters, different on every request. The last <script nonce> is the prefetching snippet: after the load event it adds <link rel="prefetch"> tags for the other chunks in the manifest, three at a time, so later pages load from cache. In the first run the header was default-src 'self'; frame-ancestors 'none' instead: the global AddSecurityHeaders middleware from Middleware ran after the route and overwrote the page’s policy, which would have blocked the nonce-tagged scripts. It now leaves an existing policy alone. In a real application the nonce and the header belong in one middleware, so every page gets them.

Subresource Integrity adds integrity="sha384-..." to the tags: install vite-plugin-manifest-sri, which writes the hashes into the manifest, and @vite uses them (Vite::useIntegrityKey(false) turns it off). SRI matters when assets are served from a CDN the application does not control. Both CSP and SRI are covered in general on Web Security Fundamentals.

Choosing a front end

Approach How it works Suits

Blade (with Alpine.js)

Server-rendered pages, small behaviour in Alpine or vanilla JS

Content sites, admin pages, teams without a JavaScript framework

Livewire

Blade components with server-side state; interactions are AJAX round trips

Interactive UIs written in PHP (Livewire)

Inertia

Laravel routes and controllers return React, Vue or Svelte pages instead of views; no separate API

Teams that want a JavaScript framework without a separate SPA (Inertia and Starter Kits)

Separate SPA or mobile app

Laravel as a JSON API with Sanctum

Several clients (Building APIs and API Resources)

All four use Vite for whatever JavaScript and CSS they have. The starter kits exist for Livewire, React, Vue and Svelte, which makes the choice mostly one of team skills.

Migrating from Laravel Mix

Laravel Mix (a webpack wrapper) was the default until Laravel 9.19 (June 2022). The plugin’s upgrade guide lists the steps: install vite and laravel-vite-plugin and remove laravel-mix; replace webpack.mix.js with vite.config.js; change the npm scripts to vite and vite build; rename MIX_ variables to VITE_ and process.env.MIX_X to import.meta.env.VITE_X; import CSS from the JavaScript entry point or list it in input; replace mix('js/app.js') with @vite('resources/js/app.js'); give files containing JSX a .jsx extension and import .vue files with their extension. Coming from plugin 2.x, the one change is the assets option described above.

Where older books and tutorials are outdated

  • Laravel Mix, webpack.mix.js and mix() (Stauffer ch. 6) were replaced by Vite in Laravel 9.19; new applications have no Mix configuration.

  • import.meta.glob(['../images/**']) in app.js for Blade-only assets stopped working with Vite 8 and laravel-vite-plugin 3; use the assets option.

  • tailwind.config.js with a content array is replaced in Tailwind 4 by @import 'tailwindcss' and @source in the CSS file.

  • Google Fonts <link> tags in the layout can be replaced by the plugin’s fonts option and @fonts (Laravel 13), which self-hosts and preloads them.

  • MIX_ environment variables and process.env are VITE_ and import.meta.env.