Route-Based Code Splitting with Vue Router

Loading every Vue view into the initial bundle defeats the point of routing; each route should ship as its own chunk that fetches on navigation. This guide wires lazy route components in Vue Router 4 with dynamic import(), groups related views into shared chunks, and adds prefetch/preload hints so transitions stay off the critical path. It is a focused companion to Code Splitting Strategies for Large Applications, which covers the vendor- and chunk-boundary design these route splits build on.

Vue Router lazy route resolution and prefetch timeline The router resolves a lazy component, fetches its chunk on navigation, while idle prefetch warms the next likely route chunk. router record component: () => import() view-dashboard.js fetched on nav view-reports.js grouped chunk view-settings.js prefetch on idle mount <router-view> async component dashed = idle prefetch of the next likely route
Figure: Vue Router resolves lazy views into per-route chunks, fetched on navigation, with idle prefetch warming the next likely route.

Problem Scope

The route table eagerly imports every view, so the entry chunk carries code for screens the user may never visit, inflating Time to Interactive. The fix is to declare each route’s component as a function returning a dynamic import(), then control how those chunks are named, grouped, and prefetched. For the chunk-graph model underneath, see Core Concepts of Modern Bundling.

Prerequisites & Reproducible Setup

# Vite 5.x / Rollup 4.x, Vue 3, Node 20+
npm create vite@latest vue-routing -- --template vue-ts
cd vue-routing && npm i
npm i vue-router@4
npm i -D rollup-plugin-visualizer@5

This assumes Vue 3 with Vue Router 4 and a Vite 5.x or 6.x build. Vue Router 4 treats any route component that is a function returning a promise as an async (lazy) component automatically — there is no separate lazy wrapper.

Diagnosis Workflow

  1. Confirm eager imports. Open the router file. Static import Dashboard from './views/Dashboard.vue' at the top means the view is in the entry chunk, not lazy.
  2. Build and inspect. Run npx vite build and check dist/assets/. If you see one large index-[hash].js and no per-view chunks, nothing is split.
  3. Visualize. Add rollup-plugin-visualizer and confirm view code is concentrated in the entry chunk rather than separate blocks.

The Solution Config

Declare each route’s component as a dynamic import. Vite/Rollup turns each into its own chunk. To group related views into one chunk, route them to the same name via the manualChunks function. Vite does not use webpack’s /* webpackChunkName */ magic comment; the equivalent is the bundler’s chunk-naming layer, shown below.

// src/router.ts — Vue Router 4, Vite 5.x
import { createRouter, createWebHistory } from 'vue-router';

const router = createRouter({
  history: createWebHistory(),
  routes: [
    {
      path: '/',
      name: 'home',
      // Lazy: this view becomes its own chunk.
      component: () => import('./views/Home.vue'),
    },
    {
      path: '/dashboard',
      name: 'dashboard',
      component: () => import('./views/Dashboard.vue'),
    },
    {
      // Settings + its children share one grouped chunk (see manualChunks below).
      path: '/settings',
      name: 'settings',
      component: () => import('./views/settings/Settings.vue'),
      children: [
        { path: 'profile', component: () => import('./views/settings/Profile.vue') },
        { path: 'billing', component: () => import('./views/settings/Billing.vue') },
      ],
    },
  ],
});

export default router;

The naming and grouping happen in the Vite config. The manualChunks function is the Vite/Rollup equivalent of webpackChunkName: return the same name for every module under a directory to co-locate them.

// vite.config.ts — Vite 5.x / Rollup 4.x
import { defineConfig } from 'vite';
import vue from '@vitejs/plugin-vue';

export default defineConfig({
  plugins: [vue()],
  build: {
    rollupOptions: {
      output: {
        // Readable, stable per-route chunk file names.
        chunkFileNames: 'assets/[name]-[hash].js',
        manualChunks(id) {
          // Group all settings views (and their children) into one chunk,
          // the Vite equivalent of /* webpackChunkName: "settings" */.
          if (id.includes('/src/views/settings/')) return 'view-settings';
          // Isolate Vue + the router so route chunks stay app-only.
          if (/[\\/]node_modules[\\/](vue|vue-router)[\\/]/.test(id)) {
            return 'vendor-vue';
          }
        },
      },
    },
  },
});

Prefetch and preload hints

By default Vite injects <link rel="modulepreload"> for chunks the entry statically depends on. Async route chunks are not preloaded — they fetch on navigation. To warm the next likely route during idle time, prefetch it explicitly. Vue Router’s resolver exposes the import, so you can trigger it on requestIdleCallback or on link hover:

// src/prefetch.ts — Vite 5.x — warm a route chunk during idle
export function prefetchRoute(loader: () => Promise<unknown>) {
  if ('requestIdleCallback' in window) {
    (window as Window).requestIdleCallback(() => void loader());
  } else {
    setTimeout(() => void loader(), 200);
  }
}

// Usage: after first paint, warm the dashboard chunk.
import { prefetchRoute } from './prefetch';
prefetchRoute(() => import('./views/Dashboard.vue'));

Calling the same () => import('./views/Dashboard.vue') the router uses means the prefetch reuses the identical chunk; the browser caches it, so the later navigation resolves instantly with no second fetch. To emit a literal <link rel="prefetch"> instead of a fetch, append it to document.head inside the idle callback with the chunk URL from the build manifest.

Verification

Rebuild and confirm per-route chunks exist and grouping worked:

# Vite 5.x — expect view-settings, vendor-vue, and per-route chunks
npx vite build
ls -1 dist/assets/*.js

You should see view-settings-[hash].js containing Settings, Profile, and Billing as one chunk, vendor-vue-[hash].js holding Vue and the router, and separate chunks for Home and Dashboard. Then serve and watch the network panel:

npx vite preview
# In DevTools > Network (JS filter, throttled): navigating to /dashboard
# should fetch exactly one new chunk; /settings/profile should fetch none
# beyond view-settings if it was already prefetched.

A correct setup shows the entry chunk shrinking by the combined size of the lazy views, and each navigation fetching one chunk rather than a waterfall. For the size budgets that catch regressions in CI, see Code Splitting Strategies for Large Applications.

Gotchas & Edge Cases

Dynamic specifiers break static chunking

A computed specifier like component: () => import(`./views/${name}.vue`) cannot be statically resolved, so Vite falls back to globbing every matching file into one chunk or warns. Keep the specifier a static string literal; use a route table, not interpolation.

Lost loading and error states

A bare lazy import shows nothing while the chunk loads. For per-route loading/error UI, wrap with defineAsyncComponent({ loader, loadingComponent, errorComponent, timeout }) instead of the plain arrow function, then reference that in the route record.

ChunkLoadError after deploy

A client holding an old index.html requests a route chunk whose hash no longer exists, and the dynamic import rejects. Add a global handler that reloads on the failure, and retain previous chunk files for a deploy cycle or two. This mirrors the React-side failure mode in Dynamic import() code splitting patterns for React.

Over-grouping defeats lazy loading

Grouping too many unrelated views into one chunk via manualChunks recreates an eager bundle by another name. Group only views that are navigated together (a settings area, a wizard flow), not the whole app.