Splitting Nested and Layout Routes Without Duplicate Chunks

The route splitting looks correct. Every route has its own chunk, the initial payload dropped, and the analyzer treemap shows four neat rectangles for the four admin routes. Then you read what is inside them:

admin-users.4f2a.js      68.4 kB
  β”œβ”€ AdminLayout.vue      14.2 kB
  β”œβ”€ PermissionsTable      9.8 kB
  └─ UsersView            44.4 kB
admin-roles.9c31.js      61.1 kB
  β”œβ”€ AdminLayout.vue      14.2 kB   ← again
  β”œβ”€ PermissionsTable      9.8 kB   ← again
  └─ RolesView            37.1 kB
admin-audit.71b0.js      59.7 kB
  β”œβ”€ AdminLayout.vue      14.2 kB   ← again
  └─ AuditView            45.5 kB

The layout is in every child chunk. A user moving between three admin screens downloads it three times, and any change to it invalidates all three chunks at once.

Root cause: duplication is the default for shared code

A dynamic import creates a chunk containing everything that entry point uniquely reaches. When four route chunks all reach AdminLayout, the bundler has a choice: duplicate it into each, or extract it into a shared chunk that all four must wait for.

The default leans toward duplication, and that default is defensible β€” it keeps every route to a single request, which matters when the shared module is 2 KB. It becomes wrong as the shared surface grows, which it always does: a layout accretes a sidebar, a permissions helper, a breadcrumb builder, a shared table component.

This is the same trade-off analysed in choosing splitChunks cache groups for shared modules, applied to the specific shape that nested routing produces.

Duplicated Layout Versus Hoisted Layout Three sibling route chunks each carrying their own copy of the layout, next to the same three chunks sharing one hoisted layout chunk fetched once per section. Duplicated β€” 189 KB across three chunks users layout 14 KB view roles layout 14 KB view audit layout 14 KB view 28 KB wasted Β· editing the layout invalidates all three chunks Hoisted β€” 161 KB, layout cached once admin-layout chunk β€” 14 KB users view roles view audit view one extra request on entering the section; sibling navigation fetches only the view Hoisting trades one request for the same bytes copied N times

Fix 1: make the layout its own lazy boundary

The most direct fix is structural. If the layout is itself a lazy route record, it gets its own chunk by construction, and its children reach it through the router rather than through an import.

// router.js β€” Vue Router 4 / Vite 5+: the layout is a lazy route of its own
const routes = [
  {
    path: '/admin',
    // Its own boundary: one chunk, fetched on entering the section.
    component: () => import('../layouts/AdminLayout.vue'),
    children: [
      { path: 'users',  component: () => import('../views/admin/Users.vue') },
      { path: 'roles',  component: () => import('../views/admin/Roles.vue') },
      { path: 'audit',  component: () => import('../views/admin/Audit.vue') },
    ],
  },
];
// routes.jsx β€” React Router 6+: the same structure
import { lazy } from 'react';

const AdminLayout = lazy(() => import('./layouts/AdminLayout'));
const Users = lazy(() => import('./views/admin/Users'));

export const routes = [
  {
    path: '/admin',
    element: <AdminLayout />,     // resolved once, reused by every child
    children: [
      { path: 'users', element: <Users /> },
      // …
    ],
  },
];

The children no longer import the layout at all β€” the router composes them β€” so the static edge that caused the duplication is gone.

Fix 2: extract what the children still share

Structure fixes the layout; it does not fix a permissions helper or a table component that three of the four children happen to import. For those, a threshold rule tells the bundler to extract rather than copy.

// webpack.config.js β€” Webpack 5
module.exports = {
  optimization: {
    splitChunks: {
      chunks: 'all',
      cacheGroups: {
        // Extract application modules reached by two or more async chunks.
        // minSize keeps the rule from creating a 3 KB shared chunk that costs
        // more in round-trips than the duplication it removes.
        sectionShared: {
          test: /[\\/]src[\\/]/,
          minChunks: 2,
          minSize: 20000,
          priority: 10,
          reuseExistingChunk: true,
          name: 'shared-admin',
        },
      },
    },
  },
};
// vite.config.js β€” Vite 5+: the assignment is explicit rather than threshold-based
export default {
  build: {
    rollupOptions: {
      output: {
        manualChunks(id) {
          // Rollup does not extract by usage count, so name the shared surface.
          if (id.includes('/src/layouts/admin/')) return 'shared-admin';
          if (id.includes('/src/components/permissions/')) return 'shared-admin';
        },
      },
    },
  },
};

The minSize floor matters as much as the minChunks threshold. Extracting every module shared by two routes produces a swarm of tiny chunks, each costing a request β€” the over-splitting failure described in component-level code splitting beyond routes.

Step-by-step verification

  1. Diff the sibling chunks. List the modules in each route chunk and confirm no application module appears in more than one.

  2. Count requests on section entry. Navigating into the section should fetch the layout chunk and one child chunk β€” two requests, not one and not four.

Two Counts That Prove the Hoist Entering the section should cost two requests and moving between siblings exactly one; any other numbers mean the layout is still duplicated or still eager. entering the section layout chunk + first child chunk sibling navigation one child chunk, nothing else Three requests on entry usually means the shared rule split too finely
  1. Count requests on sibling navigation. Moving between children should fetch exactly one new chunk.

  2. Check the shared chunk’s contents. Anything in it that only one route uses should be moved back; anything above the threshold that is still duplicated means the rule is not matching.

  3. Verify cache behaviour after a layout change. Editing the layout should change only the layout chunk’s hash, leaving the child chunks’ filenames untouched β€” the stability goal described in stabilizing chunk hashes to maximize cache hits.

  4. Re-measure the deepest route. The total bytes to reach the most nested route should be lower than before, not merely redistributed.

Edge cases and gotchas

Deeply nested sections. Three levels of layout produce three shared chunks and three requests before the leaf renders. Beyond two levels, consider merging the intermediate layouts β€” the request chain costs more than the duplication saved.

A layout used by only one child. Hoisting here adds a request and shares nothing. Keep single-child layouts inside the child chunk.

Route-level data loaders. If loaders live in the same module as the view, a shared loader utility is duplicated exactly like a shared component and needs the same treatment.

Sibling navigation preloading. Once the layout is shared, prefetching a sibling’s view chunk on hover becomes cheap and effective, since only the small view remains to fetch β€” the technique in prefetch and preload strategies for critical routes.

Depth Costs Round-Trips Each nested layout adds a chunk that must arrive before the leaf can render, so a three-level hierarchy serialises three fetches ahead of the view. layout 1 layout 2 layout 3 the actual view Past two levels, merge the intermediate layouts β€” the chain costs more than the duplication

FAQ

Why does the same module appear in four route chunks?

Because each of the four routes imports it and no rule told the bundler to extract it. A dynamic import creates a chunk containing everything that route uniquely reaches, and when four routes each reach the same shared module, duplicating it is the default: it keeps every route to a single request. That is a reasonable trade for a small module and a poor one for a large shared layout, which is why the extraction threshold is configurable.

Should a layout route be lazy or eager?

Lazy, if the section it wraps is not the landing route. A layout only matters once the user enters its section, so bundling it into the initial chunk ships it to every session including those that never navigate there. Making it a lazy route record of its own gives it one chunk, fetched once on entering the section and reused across every child navigation within it.

Does extracting a shared chunk add a request to every navigation?

Only to the first navigation into the section. After that the shared chunk is cached and in memory, so moving between sibling routes fetches only the new child chunk. The trade is one extra request once, against the same bytes duplicated in every child chunk β€” which for a shared layout of any size is decisively worth it.