A Laravel and Vue application often starts with one small JavaScript entry point. Then the application grows.
Charts arrive. Rich text editors follow. Admin panels, maps, file managers, and reporting screens join the bundle. If those modules are imported eagerly, every visitor pays for code they may never execute.
Code splitting changes that trade-off. Your application remains a single Inertia-powered experience, but the browser downloads only the code required for the current screen.
Inertia 3.x enables page-level code splitting by default. Vite handles the resulting dynamic imports and production chunks. Vue’s defineAsyncComponent takes care of heavy components inside each page.
The result is a smaller initial download and a faster boot path.
Why Laravel + Vue bundles grow over time
Bundle growth rarely comes from one bad import. It usually comes from many reasonable decisions.
A dashboard imports a charting library. A content screen imports an editor. A settings page imports a permissions matrix. Shared layouts accumulate icons, date utilities, validation helpers, and navigation logic.
Eventually, the entry bundle contains code for several unrelated user journeys.
The browser must then download, parse, and evaluate that code before the application becomes interactive. Network speed matters, but JavaScript parsing and execution matter too.
Code splitting lets you separate those journeys:
- The initial bundle contains the Inertia runtime, Vue, layouts, and shared UI.
- Each Inertia page becomes an asynchronous chunk.
- Heavy nested components load only when they render.
- Optional server data arrives after the first page is visible.
A SPA can remain one application without being one download.
Inertia 3.x splits page components by default
The current Inertia Vite integration lazy-loads page components by default. With the Vite plugin, the minimal client setup is:
import { createInertiaApp } from '@inertiajs/vue3'
createInertiaApp()
The plugin discovers your page components and resolves them asynchronously. You can also make the behavior explicit through the pages option:
createInertiaApp({
pages: {
path: './Pages',
lazy: true,
},
})
The lazy option defaults to true. Setting it to false eagerly bundles all pages into one file:
createInertiaApp({
pages: {
path: './Pages',
lazy: false,
},
})
Eager loading can suit a small application with few screens. It is rarely the better default once the application has several distinct areas.
If you manage page resolution manually, use Vite’s import.meta.glob():
import { createApp, h } from 'vue'
import { createInertiaApp } from '@inertiajs/vue3'
createInertiaApp({
resolve: name => {
const pages = import.meta.glob('./Pages/**/*.vue')
return pages[`./Pages/${name}.vue`]()
},
setup({ el, App, props, plugin }) {
createApp({
render: () => h(App, props),
})
.use(plugin)
.mount(el)
},
})
Without { eager: true }, Vite turns each matched file into a lazy import. In production, those imports become separate chunks.
You can also use Laravel’s resolvePageComponent helper:
import { resolvePageComponent } from 'laravel-vite-plugin/inertia-helpers'
createInertiaApp({
resolve: name => resolvePageComponent(
`./Pages/${name}.vue`,
import.meta.glob('./Pages/**/*.vue'),
),
})
See the Inertia code splitting documentation and Laravel’s Vite documentation for the surrounding setup.

Lazy-load heavy Vue components with defineAsyncComponent
Page-level splitting does not automatically split every component imported by that page.
A report page can still become large if it eagerly imports a chart library, editor, or map. Vue provides defineAsyncComponent for this case:
<script setup>
import { defineAsyncComponent } from 'vue'
const RevenueChart = defineAsyncComponent(() =>
import('@/Components/Reports/RevenueChart.vue')
)
</script>
<template>
<RevenueChart :data="revenue" />
</template>
The import() call creates a split point. Vite emits the component and its dependencies as an asynchronous chunk.
The request happens when Vue renders the component. That makes the pattern useful for components behind tabs, dialogs, feature flags, or permission checks:
<template>
<button @click="showChart = true">
View revenue
</button>
<RevenueChart
v-if="showChart"
:data="revenue"
/>
</template>
For a better loading experience, provide lightweight loading and error components:
<script setup>
import { defineAsyncComponent } from 'vue'
import ChartSkeleton from '@/Components/ChartSkeleton.vue'
import ChartError from '@/Components/ChartError.vue'
const RevenueChart = defineAsyncComponent({
loader: () => import('@/Components/Reports/RevenueChart.vue'),
loadingComponent: ChartSkeleton,
errorComponent: ChartError,
delay: 200,
timeout: 10_000,
})
</script>
Keep the loading component synchronous and small. Otherwise, the fallback can introduce another delay.
Use this technique for genuinely expensive modules. Splitting every small button creates extra requests and more complicated loading states. The goal is not the highest possible chunk count. The goal is a sensible initial path.
The Vue async components documentation covers additional loading and error handling options.
Combine code splitting with deferred Inertia props
Code splitting reduces JavaScript. Deferred props reduce the initial data response.
These solve different parts of the same performance problem.
Imagine a reporting page with a title, date range, summary values, and a detailed time series. The summary belongs in the first render. The time series may take longer to calculate and may only support a chart below the fold.
On the Laravel side, defer the expensive prop:
use Inertia\Inertia;
return Inertia::render('Reports/Show', [
'report' => $report,
'summary' => $report->summary(),
'series' => Inertia::defer(
fn () => $report->series(),
'charts',
),
]);
Inertia skips the deferred value during the initial response. It resolves the closure in a follow-up request after the page renders.
On the Vue side, use Deferred with the async component:
<script setup>
import { Deferred } from '@inertiajs/vue3'
import { defineAsyncComponent } from 'vue'
import ChartSkeleton from '@/Components/ChartSkeleton.vue'
const RevenueChart = defineAsyncComponent(() =>
import('@/Components/Reports/RevenueChart.vue')
)
</script>
<template>
<section>
<h1>{{ report.name }}</h1>
<SummaryCards :summary="summary" />
</section>
<Deferred data="series">
<template #fallback>
<ChartSkeleton />
</template>
<RevenueChart :data="series" />
</Deferred>
</template>
This creates two independent delays:
- The chart component JavaScript loads only when the deferred content renders.
- The chart data arrives in a separate request after the initial page is visible.
You can group related deferred props:
return Inertia::render('Reports/Show', [
'series' => Inertia::defer(fn () => $report->series(), 'charts'),
'annotations' => Inertia::defer(
fn () => $report->annotations(),
'charts',
),
]);
Both values are fetched together under the charts group.
For existing pages, partial reloads offer a more targeted option:
import { router } from '@inertiajs/vue3'
function refreshStats() {
router.reload({
only: ['stats'],
preserveState: true,
preserveScroll: true,
})
}
On the server, wrap optional data in closures so Laravel does not calculate it unless requested:
return Inertia::render('Dashboard', [
'users' => fn () => User::query()->latest()->get(),
'stats' => Inertia::optional(
fn () => DashboardStats::for($team),
),
]);
Read the Inertia deferred props documentation and partial reload documentation when deciding which approach fits a page.

Name chunks for operations, not vanity
Vite and Rollup generate production chunk names for you. Those names normally include a content hash, which is important for safe browser caching.
A hashed asset might look like:
Reports-Show-Cm8k2Lx.js
When the file changes, its URL changes. Browsers can keep the old version cached without serving stale code for a new deployment.
If your deployment or CDN needs a predictable directory structure, configure the output pattern while keeping the hash:
import { defineConfig } from 'vite'
export default defineConfig({
build: {
rollupOptions: {
output: {
chunkFileNames: 'build/chunks/[name]-[hash].js',
},
},
},
})
Avoid removing [hash]. Stable filenames make cache invalidation harder.
You can also define manual shared chunks when several pages use the same large dependency:
export default defineConfig({
build: {
rollupOptions: {
output: {
manualChunks: {
charts: ['chart.js'],
},
},
},
},
})
Use manual chunks carefully. A shared chart chunk can prevent duplication, but it may also move that dependency into the initial request if the entry point imports it. Check the generated output instead of assuming the result.
Vite’s glob import documentation explains how lazy imports become separate chunks. Its production build also handles common chunk preloading to reduce avoidable request chains.
Measure the win before and after
Code splitting is useful when it improves the user’s path. Measure that path directly.
Start with a production build:
npm run build
Inspect the generated asset sizes and identify the entry chunk, page chunks, and large shared dependencies. Then compare the initial page in a throttled browser profile.
Track:
- Initial JavaScript transferred.
- JavaScript parsed and evaluated.
- Largest Contentful Paint.
- First Contentful Paint.
- Time to interactive or interaction readiness.
- Requests needed for the first meaningful screen.
- Time spent waiting for deferred props.
- The size of the largest page chunk.
Use Chrome DevTools’ Network and Performance panels alongside Lighthouse. Test a cold cache and a warm cache. A first visit measures boot cost; a repeat visit shows how well your chunks are being reused.
A useful comparison looks like this:
| Measurement | Before | After |
|---|---|---|
| Initial JavaScript | 640 KB | 210 KB |
| Report page chunk | Included | Loaded on demand |
| Chart library | Included | Deferred |
| Initial JSON payload | 180 KB | 42 KB |
| Chart data | Initial response | Deferred request |
These numbers are examples, not targets. Your application’s route mix, dependency graph, and user devices determine the real result.

A practical pattern for growing applications
For most Laravel and Vue applications, a sensible strategy looks like this:
- Keep Inertia page resolution lazy.
- Keep layouts and navigation lightweight.
- Split charts, editors, maps, and admin-only modules with
defineAsyncComponent. - Render async components behind real conditions such as tabs or dialogs.
- Defer expensive props with
Inertia::defer(). - Group deferred data by the UI section that consumes it.
- Keep production chunk filenames hashed.
- Measure the initial route on a cold cache.
- Recheck shared dependencies after each major feature.
This approach works whether Laravel serves a traditional web application, an Inertia SPA, or both. The same PHP web framework may also power an application that needs to build REST API with PHP; code splitting affects the browser client, not those API responses.
For PHP teams, Vite, Inertia, Vue, and Laravel’s deployment tooling form a practical set of php developer tools. Each handles a different boundary: bundling, navigation, rendering, server data, and production delivery.
The best split is not the one with the most files. It is the one that keeps the first screen small and makes the rest of the application arrive when the user needs it.