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.

The problem exists because a router’s route table is, by default, an ordinary JavaScript module: every import at the top of it is a static edge in the module graph, and Rollup — the bundler underneath Vite — pulls every statically-reachable module into whichever entry can reach it. A route table that imports Dashboard, Reports, and Settings at the top therefore drags all three views, plus their transitive dependencies (charting libraries, form validators, date pickers), into the single entry chunk the browser must parse before the app is interactive. The cost is not the download alone; it is the parse-and-compile time of code for screens the visitor may never open. On a marketing landing route, shipping the entire authenticated settings area is pure waste paid on every first visit.

Route-based splitting fixes this at the exact seam where the module graph already forks — the route boundary. A user on / cannot see /settings without a navigation, and a navigation is an async event, so there is no correctness reason the settings code must be present at load. Turning each route component into a function that returns import() converts that static edge into a dynamic one, which Rollup treats as a split point: it emits the target and its private dependencies as a separate chunk, reachable only when the function runs. This page walks the full loop — proving the code is eager, cutting the split points, controlling how chunks are named and grouped, warming the next route on idle, and verifying that navigation fetches exactly one file instead of a waterfall.

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.

The distinction that matters is static versus dynamic import, not lazy versus eager as a design intention. import X from './X.vue' is a static specifier: the bundler resolves it at build time, follows the edge, and merges the module into the importing chunk. () => import('./X.vue') is a dynamic import expression: the bundler still resolves the specifier at build time (that is why the string must be statically analyzable), but it emits the target as a separate chunk and replaces the call with a runtime loader that fetches that chunk on demand. Vue Router never sees the difference — it only knows that a component value which is a function returning a promise is an async component it should resolve during navigation. All of the splitting behaviour comes from the bundler reading the shape of the import, not from the router.

If you get this wrong the symptom is silent: the app works, tests pass, and the only evidence is a fat entry chunk and a slow first paint. That is what makes route splitting a build-tooling concern rather than a feature — nothing in the running application tells you the code failed to split, so you have to inspect the emitted output to know.

Eager route table versus lazy route components An eager route table imports every view statically so the entry chunk carries all screens; declaring each component as a function returning a dynamic import gives each view its own chunk fetched on navigation. eager route table import View from './View.vue' all views in the entry chunk lazy components component: () => import('./View.vue') one chunk per view, fetched on nav
Figure: the only change is wrapping the import in a function — that alone makes the view its own chunk.

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. This is a deliberate contrast with React Router, where the route element is an eagerly-evaluated JSX node and you must reach for React.lazy plus a Suspense boundary to defer it. In Vue the router itself is the suspense boundary for route-level components: it holds the pending navigation until the promise resolves, mounts the resolved component into <router-view>, and only then completes the transition. That means the same arrow function serves two jobs at once — it is the bundler’s split-point marker and the router’s async loader — which is why the prefetch trick later in this guide can reuse the exact function the route record already holds.

The rollup-plugin-visualizer dependency is not required to ship; it is a diagnostic. It reads Rollup’s bundle metadata after a build and renders a treemap of which modules landed in which chunk, so you can see view code that failed to split as a coloured block inside the entry rather than as its own file. Keep it in devDependencies and gate it behind a flag so it does not run on every production build.

Vue Router 4 lazy components need no wrapper Unlike React which needs a lazy wrapper, Vue Router 4 treats any component that is a function returning a promise as an async lazy component automatically. component: () => import(...)a function returning a promise auto async componentno lazy wrapper needed
Figure: Vue Router recognizes the promise-returning function itself — no React.lazy equivalent required.

Diagnosis Workflow

Three-step split diagnosis Confirm eager static imports in the router file, build and inspect dist for a single large index chunk with no per-view chunks, then visualize to see view code concentrated in the entry. 1 · eager imports?static top-level 2 · inspect distone big index? 3 · visualizeviews in entry?
Figure: the router source, the emitted chunks, and the treemap all confirm the same thing three ways.
  1. Confirm eager imports. Open the router file. A static import Dashboard from './views/Dashboard.vue' at the top, with the identifier used bare as component: Dashboard, means the view is a static edge and lives in the entry chunk, not a lazy one. The tell is the top-of-file import list: if every view is named there, the route table is eager regardless of how the routes array reads. The fix is to delete those top-level imports and inline each as component: () => import(...). Confirm by re-reading the file and checking that no view is referenced by a bare identifier — every component should be an arrow function.
  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; the number of emitted JS files is a direct proxy for the number of live split points. Root cause is almost always that the imports are still static, or that a manualChunks rule is folding the views back together. Confirm the fix by counting files: after splitting you expect one entry, one vendor-vue, and one chunk per lazy route or route group. A build that emits three JS files for a ten-view app has not split.
  3. Visualize. Add rollup-plugin-visualizer and confirm view code is concentrated in the entry chunk rather than separate blocks. The treemap turns “is it split?” into a picture: a correctly split app shows each view as its own top-level rectangle, while an unsplit one shows the views nested inside the entry’s rectangle. Hover any block to read its byte size, which also tells you whether a single heavy dependency — a chart library pulled into one view — is the real weight rather than the view code itself. Confirm by re-running the build after adding the split points and watching the view blocks move out of the entry.

Wiring the visualizer is a two-line change to the config. It emits a static HTML report next to the bundle that you open once and discard; do not commit it or serve it:

// vite.config.ts — Vite 5.x — diagnostic only, gate behind an env flag
import { visualizer } from 'rollup-plugin-visualizer';

export default defineConfig({
  plugins: [
    vue(),
    // ANALYZE=1 npx vite build → writes dist/stats.html (treemap)
    process.env.ANALYZE ? visualizer({ filename: 'dist/stats.html', gzipSize: true }) : null,
  ],
});

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.

Three levers: lazy import, grouping, prefetch Each route component is a dynamic import so it becomes its own chunk; manualChunks co-locates a settings area into one chunk and isolates Vue and the router into vendor-vue; and idle prefetch warms the next likely route using the same import the router uses. lazy importone chunk per view manualChunks groupingsettings area → one chunkvendor-vue isolated idle prefetchsame import = same chunkinstant later nav
Figure: split, group, and warm — the prefetch reuses the router's own import so nothing is fetched twice.
// 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;

A note on the route table above: the three top-level routes each become their own chunk, but the /settings route and its two children resolve through three separate import() calls, which by default would be three separate chunks. That is where the manualChunks rule earns its place — it collapses those three files back into one view-settings chunk without you having to change the route records. The children keep their own import() calls (the router needs them to resolve nested routes), and the bundler still merges the resulting modules because they share a target name. This decoupling is the point: the route table expresses navigation structure, the config expresses chunk boundaries, and neither has to know about the other.

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';
          }
        },
      },
    },
  },
});

How it works under the hood

The manualChunks(id) function runs once per module during Rollup’s chunking phase, after the module graph is built but before chunks are written. The id is the resolved absolute path of a module; returning a string assigns that module to a named chunk, and returning undefined lets Rollup place it by its default algorithm — which is to give each dynamic-import target its own chunk and hoist shared code into common chunks. Because the function is called per module, the order of your if branches is the priority order: the settings check runs before the node_modules check, so a settings view that happens to sit under node_modules (it will not, but the principle holds) would land in view-settings. Put the most specific rule first and the vendor catch-all last.

Chunk file names carry a content hash for cache-busting. [name] resolves to the chunk name — view-settings, vendor-vue, or the entry’s default — and [hash] is derived from the chunk’s final content after tree-shaking and minification. Change one byte of a settings view and only view-settings-[hash].js gets a new hash; vendor-vue and every other route chunk keep their old names and stay in the browser cache. This is the real payoff of isolating vendor code: framework bytes that rarely change get a stable URL, so a routine app deploy does not invalidate Vue and the router for returning visitors. Grouping unrelated views into one chunk throws that away, because any edit to any view rotates the shared hash and forces the whole group to re-download.

One caveat on hashing: it is content-addressed but also dependency-aware. If view-settings imports a utility that also lands in a shared chunk, and that shared chunk’s hash changes, view-settings’s hash changes too because its import reference now points at a new filename. This cascade is why a stray dependency leaking into a hot vendor chunk can quietly invalidate route chunks that never changed — inspect the manifest, not just the source, when a deploy busts more cache than expected.

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.

The distinction between the two approaches is priority and side effects. Calling the loader function issues a normal fetch at default priority and, critically, evaluates the module — the chunk is not just downloaded but parsed and its top-level code run, so any import side effects fire early. A <link rel="prefetch"> downloads the file at the browser’s lowest priority into the HTTP cache without evaluating it, which is safer when the module has side effects but slower to activate because the eventual navigation still has to parse it. Use the loader call when you are confident the user is about to navigate (link hover, a wizard’s next step) and the prefetch link when you are merely hedging (warming a likely-but-not-certain route on idle). Do not do both for the same route; you will download it twice unless the cache coalesces them, which it does not guarantee across priorities.

Hover-based prefetch is the highest-signal trigger because intent is measurable: a pointer resting on a link for even 100ms predicts a click far better than an idle timer does. Wire it on the <router-link> with @mouseenter, debounced so a mouse sweeping across a nav bar does not warm every route at once:

// src/useHoverPrefetch.ts — Vite 5.x — warm a route chunk on intent
const warmed = new Set<string>();

export function warmOnHover(key: string, loader: () => Promise<unknown>) {
  if (warmed.has(key)) return;      // fetch each chunk at most once
  warmed.add(key);
  void loader();                    // reuses the router's own import
}

The warmed set is not an optimisation detail; it is correctness. Without it, a user who moves the pointer on and off a link repeatedly triggers a loader call each time, and while the browser cache absorbs the network cost, the promise machinery and module evaluation still churn. Deduplicating at the call site keeps the prefetch to one fetch per chunk for the life of the page.

Verification

Expected chunks and network behaviour A correct build emits a view-settings chunk holding Settings, Profile and Billing, a vendor-vue chunk, and separate Home and Dashboard chunks; navigating to a route fetches exactly one new chunk, and a prefetched route fetches none. view-settings chunk = 3 views grouped one file, not three navigate to /dashboard exactly one new chunk prefetched /settings/profile no new fetch
Figure: grouping shows in the file list; prefetch shows as an absent fetch on the second navigation.

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. A waterfall — chunk A loads, then discovers it needs chunk B, which discovers chunk C — is the failure this design specifically avoids: because the route chunk and its private dependencies are emitted together, one navigation is one round trip. If DevTools shows a chain of dependent requests on a single navigation, a shared dependency is being emitted as a separate chunk that the route chunk imports at runtime; check whether an over-eager manualChunks rule split a utility the view needs synchronously. For the size budgets that catch regressions in CI, see Code Splitting Strategies for Large Applications.

CI integration

The manual DevTools check does not survive contact with a team; the only durable guard is a size budget in the build. Because the number of emitted chunks is a proxy for live split points, the cheapest regression test asserts the entry chunk stays under a byte ceiling — if someone reverts a route to a static import, the views fold back into the entry, its size jumps, and the build fails:

# CI — Vite 5.x — fail the build if the entry chunk regresses past budget
npx vite build
node -e '
  const fs = require("fs"), path = "dist/assets";
  const entry = fs.readdirSync(path).find(f => f.startsWith("index-") && f.endsWith(".js"));
  const kb = fs.statSync(path + "/" + entry).size / 1024;
  if (kb > 180) { console.error(`entry ${kb.toFixed(1)}kb over 180kb budget`); process.exit(1); }
'

Set the ceiling just above the current entry size so any meaningful regression trips it, and raise it deliberately when the app genuinely grows. Rollup’s own output.experimentalMinChunkSize and the visualizer’s JSON output are more precise instruments, but a single byte-count assertion catches the most common regression — a static import sneaking back in — with no new dependency.

Gotchas & Edge Cases

Four Vue Router splitting edge cases A computed specifier breaks static chunking; a bare lazy import shows no loading state; a stale index.html causes ChunkLoadError after deploy; and over-grouping views recreates an eager bundle. dynamic specifier import(`./${name}.vue`) globs or warns — keep a static string literal. lost loading/error UI wrap with defineAsyncComponent for loadingComponent + errorComponent. ChunkLoadError stale index.html hits a gone hash — reload handler + retain old chunks. over-grouping grouping unrelated views recreates an eager bundle — group only co-navigated views.
Figure: one static-analysis trap, one UX gap, one deploy failure, and one "you undid the split" trap.

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. The symptom is a build warning about a dynamic import that could not be analysed, followed by a single fat chunk containing every file the pattern could match — the exact opposite of splitting. The root cause is that the bundler must know the target at build time to emit a chunk for it; a runtime-computed string forces it to either bundle all candidates or give up. The fix is to keep the specifier a static string literal and select the route through the route table, not through interpolation. Confirm by checking the build log is warning-free and that the glob-matched files appear as distinct chunks. If you genuinely need dynamic selection, import.meta.glob gives you an explicit, analyzable map of specifiers to loaders — but a plain route table is almost always the right answer.

Lost loading and error states

A bare lazy import shows nothing while the chunk loads. On a fast connection this is invisible, but on a throttled network the user sees a blank <router-view> for the duration of the fetch, with no spinner and no recovery path if the fetch fails. The root cause is that the plain arrow function resolves to a component or rejects, with no states in between that the router can render. The fix is to wrap the loader with defineAsyncComponent({ loader, loadingComponent, errorComponent, delay, timeout }) instead of the plain arrow function, then reference that in the route record; delay suppresses the spinner on fast loads so it does not flash, and timeout gives the error component a deadline. Confirm by throttling to Slow 3G in DevTools and navigating: you should see the loading component, then the view, and the error component if you block the chunk request. Note this changes the value you pass to component, but not its shape — it is still a function-like async component the router resolves lazily, so the split point is unchanged.

ChunkLoadError after deploy

A client holding an old index.html requests a route chunk whose hash no longer exists, and the dynamic import rejects. The symptom is a ChunkLoadError or a bare Failed to fetch dynamically imported module in the console, always after a deploy and always for a route the user had not yet visited — the entry chunk they loaded before the deploy still points at chunk hashes the new deploy renamed. The root cause is the content hash doing its job: new content, new filename, and the old filename is gone from the server. The fix has two halves. First, retain previous chunk files for a deploy cycle or two so in-flight sessions can still fetch what their index.html references; on immutable object storage this is just not deleting old assets immediately. Second, add a global handler that catches the rejection and does a full reload, which pulls a fresh index.html with current hashes:

// src/main.ts — Vite 5.x — recover from a stale-chunk rejection once
window.addEventListener('vite:preloadError', (e) => {
  e.preventDefault();                 // stop the unhandled rejection
  if (!sessionStorage.getItem('chunk-reloaded')) {
    sessionStorage.setItem('chunk-reloaded', '1');
    window.location.reload();         // fetch fresh index.html + hashes
  }
});

The sessionStorage guard prevents a reload loop: if the reload itself cannot fetch the chunk (a genuinely broken deploy, not a stale client), you reload once and then surface the error rather than trapping the user in a refresh cycle. Confirm the recovery by deploying a hash change while a tab sits open on an old build, then navigating to an unvisited route — it should reload once and land on the target. 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. The symptom is a large route chunk that downloads code for screens the user did not navigate to — you split the entry only to rebuild the same problem one level down. The root cause is treating manualChunks as an organisational tool (“all views in one chunk”) rather than a co-navigation tool. The guiding rule is to group only views that are reliably visited together within one session: a settings area whose tabs the user flips between, a multi-step wizard, an onboarding flow. Views reached from unrelated entry points belong in separate chunks. The tension with hashing compounds the cost — a wide group means any edit to any member rotates the shared hash and re-downloads the whole group, so over-grouping hurts both first load and cache stability. Confirm your grouping is honest by opening the treemap and asking, for each grouped chunk, whether a typical session that loads it uses most of it.

When not to split a route

Splitting is not free, and some routes are better left in the entry. A route the user hits on essentially every first visit — a landing page, a login screen, the default authenticated home — gains nothing from its own chunk, because the navigation to it is the initial load and the split just adds a round trip. Likewise a view whose code is a few kilobytes is not worth a separate request; the HTTP overhead of fetching a tiny chunk can exceed the parse cost you saved. The heuristic: split routes that are large, optional, or behind a gate (auth, a rarely-used admin area, a heavy report builder), and keep small, always-visited routes in the entry. Rollup’s output.experimentalMinChunkSize can automatically fold sub-threshold chunks back into a parent, which is a reasonable backstop, but deciding at the route table is clearer than tuning a byte threshold after the fact.