Most Vite + Laravel setups use a single bundle. That works for small apps but breaks down when you have separate frontend, auth, and admin areas — the auth bundle pulls in admin code, the public site loads the React app it doesn't need, and your vendor.js becomes 800kb of conflicting dependencies.
Here's how I split this portfolio into three independent Vite bundles with HMR working across all of them.
The Three Bundles
resources/
js/
frontend.tsx # Public site — Alpine.js + Particles, no React
auth.tsx # Auth pages — React + Inertia (login, register)
dashboard.tsx # Admin — React + Inertia (CMS)
scss/
frontend.scss
auth.scss
dashboard.scss
Each bundle has its own dependencies, its own CSS, and its own entry point.
vite.config.js
import { defineConfig } from 'vite';
import laravel from 'laravel-vite-plugin';
import react from '@vitejs/plugin-react';
import tailwindcss from '@tailwindcss/vite';
import path from 'path';
export default defineConfig({
plugins: [
laravel({
input: [
'resources/js/frontend.tsx',
'resources/js/auth.tsx',
'resources/js/dashboard.tsx',
'resources/scss/frontend.scss',
'resources/scss/auth.scss',
'resources/scss/dashboard.scss',
],
refresh: true,
}),
react(),
tailwindcss(),
],
resolve: {
alias: {
'@': path.resolve(__dirname, 'resources/js'),
'@scss': path.resolve(__dirname, 'resources/scss'),
'@assets': path.resolve(__dirname, 'resources/assets'),
},
},
});
The Laravel plugin reads each input and creates a separate manifest entry. Vite handles the rest.
In Blade
Each layout pulls only what it needs:
{{-- layouts/app.blade.php (public site) --}}
@vite(['resources/js/frontend.tsx', 'resources/scss/frontend.scss'])
{{-- layouts/auth.blade.php --}}
@vite(['resources/js/auth.tsx', 'resources/scss/auth.scss'])
{{-- layouts/dashboard.blade.php --}}
@vite(['resources/js/dashboard.tsx', 'resources/scss/dashboard.scss'])
Vite generates separate manifest entries with separate hashed filenames. The browser only downloads what the current page uses.
Conditional Inertia
Auth and dashboard share Inertia setup but the public site doesn't have it. Each bundle does its own bootstrapping:
// auth.tsx
import { createInertiaApp } from '@inertiajs/react';
createInertiaApp({
resolve: (name) => import(`./Pages/Auth/${name}.tsx`),
setup: ({ el, App, props }) => createRoot(el).render(<App {...props} />),
});
// dashboard.tsx
createInertiaApp({
resolve: (name) => import(`./Pages/Dashboard/${name}.tsx`),
setup: ({ el, App, props }) => createRoot(el).render(<App {...props} />),
});
The page resolvers are scoped — auth.tsx can't accidentally import a dashboard page.
Bundle Size
Before splitting (single bundle): ~620kb gzipped, every page.
After splitting:
- Frontend: ~180kb (Alpine + utilities, no React)
- Auth: ~240kb (React + Inertia + auth-only pages)
- Dashboard: ~410kb (React + Inertia + CodeMirror + admin)
The public site's gain is the biggest — frontend visitors no longer download admin code.
HMR Across Bundles
This was the part that took longest to get right. With multiple bundles, Vite's HMR needs to know which bundle changed and only push updates to clients running that bundle.
The Laravel plugin handles this automatically — but only if your .env's VITE_APP_URL matches the dev server. Mismatch and HMR silently fails on auth/dashboard.
APP_URL=http://localhost:8000
VITE_APP_URL=http://localhost:5173
If HMR works on frontend.tsx but not dashboard.tsx, this is almost always the cause.
Production Build
yarn build
Output:
public/build/
manifest.json
assets/
frontend-a8c.js (180kb)
auth-3f1.js (240kb)
dashboard-7e2.js (410kb)
frontend-2bc.css
auth-5d8.css
dashboard-9af.css
The Laravel plugin's @vite() directive reads the manifest and outputs the right <script> tags per layout.
What I'd Avoid
- Lazy-loading all pages. Inertia's
import(...)already does this. Going further (route-level code splitting) on top adds complexity for marginal gain on this size of app. - Sharing a single
vendorchunk across bundles. Tempting, but Vite's manualChunks API across multiple inputs gets fragile. Let each bundle have its own vendor code — duplication is fine at this scale. - Skipping the alias config. Without
@/paths, every move of a component cascades into 30 import path edits. Not worth it.
Al Amin Ahamed
Senior software engineer & AI practitioner. Laravel, PHP, WordPress plugins, WooCommerce extensions.
About me →More from the blog
← Older
Caddy + Laravel: A 50-Line Production Config
Newer →
React 19 in Production: useTransition, useOptimistic, and the End of forwardRef
One email a month. No noise.
What I shipped, what I read, occasional deep dive. Unsubscribe anytime.
Check your inbox — confirmation link sent.