How to Code-Split Vue Router Routes
This guide applies Dynamic Imports and Route-Based Splitting to Vue applications, within JavaScript Bundle Optimization & Code Splitting. A Vue single-page application that imports every page component statically ships the whole app on the first visit: the settings screens, the admin tools, the checkout flow and the marketing pages all parse before the first route renders. On mid-tier phones that inflates Total Blocking Time and pushes early interactions past the 200ms INP budget.
Vue Router supports lazy routes natively: pass a function returning a dynamic import as the route's component, and the bundler emits a separate chunk loaded on first navigation. The decisions that matter are which routes to split, how to group them, which to keep eager, and how to keep first visits to split routes fast.
Rapid Diagnosis
- Check route definitions.
component: HomeView(static import) means the route is in the main bundle;component: () => import('./views/HomeView.vue')means it is split. - Look at the bundle treemap for views that appear in the entry chunk.
- Check the landing route. If it is lazy, the first load fetches the entry, then the route chunk — a waterfall on the most important navigation.
- Watch first visits to split routes in a throttled trace: is the chunk requested on click?
Root Cause Analysis
1. Static imports of views. The default scaffolding often imports views statically for simplicity.
2. Lazy landing route. Splitting the route users land on adds a round trip to LCP for client-rendered apps.
3. Over-splitting. Every small view in its own chunk produces many requests and loading flashes during navigation.
4. No prefetching. Lazy routes without prefetch make every first visit wait for the chunk.
Step-by-Step Resolution
1. Make non-landing routes lazy
// router.js
import { createRouter, createWebHistory } from 'vue-router';
import HomeView from './views/HomeView.vue'; // landing: eager
export default createRouter({
history: createWebHistory(),
routes: [
{ path: '/', component: HomeView },
{ path: '/search', component: () => import('./views/SearchView.vue') },
{ path: '/product/:id', component: () => import('./views/ProductView.vue') },
{ path: '/account', component: () => import('./views/AccountView.vue') },
],
});
// trade-off: if users land on many different routes (deep links from search),
// "the landing route" is not one route. Server rendering (Nuxt) or eager loading
// of the top two or three landing templates is the better answer then.
Expected outcome: views not needed for the first render leave the entry chunk.
2. Group related routes into shared chunks
With Vite, use a manualChunks rule by path; with webpack, use magic comments.
// webpack: name chunks so related routes share one.
{ path: '/account', component: () => import(/* webpackChunkName: "account" */ './views/AccountView.vue') },
{ path: '/account/orders', component: () => import(/* webpackChunkName: "account" */ './views/OrdersView.vue') },
// Vite: group by directory in vite.config.js
// manualChunks(id) { if (id.includes('/src/views/account/')) return 'account'; }
// trade-off: grouping means visiting one account page loads all account views.
// Group only routes users typically visit together in a session.
Expected outcome: fewer, purposeful chunks; navigating within a group needs no further requests.
3. Prefetch likely next routes
Prefetch the core group after the landing route is interactive, and other groups on hover of their links. Nuxt and many Vite setups emit prefetch hints automatically; in a plain Vue Router app, trigger the import on intent.
// Prefetch the core group when the browser is idle after load.
addEventListener('load', () => requestIdleCallback(() => {
import('./views/SearchView.vue'); import('./views/ProductView.vue');
}));
// trade-off: idle prefetch spends bandwidth on users who never navigate. Gate it
// on connection type and skip it when saveData is set.
Expected outcome: first visits to core routes feel as fast as cached ones. The general technique is in prefetching route chunks on hover and viewport.
4. Avoid loading flashes during navigation
Vue Router waits for lazy components to resolve before completing navigation, so the old view stays visible until the new one is ready. Show progress with a thin top bar driven by beforeEach/afterEach rather than replacing the view with a spinner.
Verification
Check the treemap: views appear in their group chunks, not the entry. In a throttled trace of the landing page, only the entry (and its static dependencies) should load. Navigate to a prefetched route: no chunk request should occur at click. Compare TBT and route transition times before and after; the landing route's TBT should drop roughly in proportion to the removed view code.
Worked Example: An E-Commerce SPA
A Vue storefront imported its 34 views statically; the entry chunk was 540KB uncompressed and lab TBT on the product listing (the main landing route) was 620ms. The team made all views lazy except the listing, grouped account and admin views, and grouped product, cart and checkout into one "shop" chunk prefetched at idle. The entry fell to 214KB, TBT to 290ms, and field INP p75 for interactions in the first five seconds improved from 260ms to 180ms. Transitions into the shop group were unchanged because of the idle prefetch; the first visit to account pages added about 250ms on mobile, which the team accepted given how rarely those pages were the first navigation.
Common Mistakes
- Lazy-loading layout components. Shared layouts used by every route belong in the entry; making them lazy delays every navigation.
- Using async components inside templates instead of at route level.
defineAsyncComponentis useful for heavy widgets, but route-level splitting is the main lever. - Forgetting chunk load error handling. After a deploy, old tabs may request chunks that no longer exist.
- Grouping by file structure instead of user journeys. Directory layout rarely matches navigation patterns.
Route-Level Data Loading Alongside Chunks
Splitting code solves half of the transition delay; the other half is usually data. If each view fetches its data in onMounted, the sequence is chunk → mount → fetch → render: a waterfall. Start the data request in a navigation guard (beforeResolve or a per-route beforeEnter) in parallel with the chunk, and pass the promise to the view. Combined with chunk prefetching, a well-structured Vue app can transition to a data-heavy route in roughly the time of one API round trip.
router.beforeResolve(async (to) => {
if (to.meta.load) to.meta.data = to.meta.load(to.params); // start fetch, do not await
});
// trade-off: not awaiting means the view must handle a pending promise. Await
// it here instead if you prefer the old view to stay until data is ready.
FAQ
Does Nuxt split routes automatically?
Yes. Nuxt generates a chunk per page and handles prefetching of linked pages through <NuxtLink>. The decisions in this guide still apply to grouping and to which heavy components inside pages should be lazy — see Vue & Nuxt performance.
Should every route be lazy in a server-rendered app?
In SSR, the initial HTML already shows the landing route, and the client loads that route's chunk to hydrate it. Lazy routes are still beneficial — they keep other routes out of the initial download — and frameworks preload the current route's chunk so there is no waterfall.
What about defineAsyncComponent for heavy widgets?
It is the Vue equivalent of React.lazy for components inside a route. Use it for editors, charts and maps that are not visible initially, with a correctly sized loading component and a delay option so fast loads do not flash a placeholder.
Related
- Implementing route-level code splitting in Next.js — the React/Next.js counterpart.
- Configuring manualChunks in Vite — grouping route chunks in Vite.
- Breaking up long tasks in Vue event handlers — the runtime half of Vue INP work.