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.
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
-
Diff the sibling chunks. List the modules in each route chunk and confirm no application module appears in more than one.
-
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.
-
Count requests on sibling navigation. Moving between children should fetch exactly one new chunk.
-
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.
-
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.
-
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.
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.
Related
- Implementing Route-Level Code Splitting in SPAs β the parent guide on route boundaries
- Code Splitting Vue Router Routes With defineAsyncComponent β the Vue-specific mechanics of lazy route records
- Choosing splitChunks Cache Groups for Shared Modules β tuning the extraction thresholds
- Finding Duplicate Dependencies in a Bundle β detecting duplication you have not noticed yet