Lazy Loading and Scroll
Split each route into its own chunk with a dynamic import, and decide where the page scrolls after a navigation.
{ path: '/reports', component: () => import('../views/ReportsView.vue') }Importing every view at the top of the router file puts every view in the first JavaScript file the browser downloads. A visitor who only opens the dashboard still pays for the billing screen. Give a route a function that returns a dynamic import instead, and the build splits that view into its own chunk.
What it does to the bundle
Write component: () => import('../views/ReportsView.vue') next to the eager form, import ReportsView from '../views/ReportsView.vue', and the difference is when the code is fetched, not what it does. Vite sees the dynamic import at build time, emits a separate chunk, and adds a preload hint. The chunk is requested when the route is first entered, then cached for the rest of the session. The initial bundle shrinks; a first visit to a split route costs one request. Leave the one or two views that almost every session opens eager, and split the rest. The /* webpackChunkName */ comment in older guides is webpack-only and does nothing under Vite.
Do not wrap it
Pass the bare function. A route component must not be an async component built with defineAsyncComponent: the router does the loading itself, and wrapping it breaks in-component guards.
Scroll position
A single-page navigation does not reset the scroll position; the new screen can appear halfway down. The scrollBehavior option on createRouter fixes that. It receives to, from and savedPosition, which is set only when the browser moves through history, and returns a position, a promise of one, or a falsy value to leave the page alone.
Example
<script setup lang="ts">
import { RouterLink } from 'vue-router'
// same specifier as the route record, so the chunk is fetched once
function warmReports(): void {
void import('../views/ReportsView.vue')
}
</script>
<template>
<nav>
<router-link to="/">Home</router-link>
<router-link
to="/reports"
@mouseenter="warmReports"
@focus="warmReports"
>
Reports
</router-link>
</nav>
</template>
<style scoped>
nav {
display: flex;
gap: 1rem;
}
nav a.router-link-active {
font-weight: 600;
}
</style>When to use it
- Cut the first download of an admin app that ships twenty screens most users never open
- Scroll a list back to where the reader was when they press the browser back button
- Start fetching a heavy screen's code when the pointer enters its link, before the click
More examples
Eager and lazy routes side by side
The two forms are interchangeable in a record, so you can split a route later without touching anything else. Home stays eager because it is on the critical path for a cold visit and splitting it would only add a request. Run the production build and read the chunk list to check the split landed: a view that is still in the entry chunk usually means something else imports it eagerly, often a stray import in a shared module.
// src/router/index.ts
import { createRouter, createWebHistory } from 'vue-router'
import HomeView from '../views/HomeView.vue'
const router = createRouter({
history: createWebHistory(import.meta.env.BASE_URL),
routes: [
// eager: part of the first bundle
{ path: '/', name: 'home', component: HomeView },
// lazy: its own chunk, fetched on first entry
{
path: '/reports',
name: 'reports',
component: () => import('../views/ReportsView.vue'),
},
{
path: '/settings',
name: 'settings',
component: () => import('../views/SettingsView.vue'),
},
],
})
export default routerControlling the scroll position
Pass this as the scrollBehavior option to createRouter. savedPosition is only populated for a history move, which is why the check comes first: restoring it is what makes the back button feel native. Returning { el } scrolls to an element by selector, with top and left read as offsets from it, so add a top offset when a fixed header would cover the target. Returning a promise defers the scroll, which is how you wait for a page transition to finish.
// src/router/scroll.ts
import type { RouterScrollBehavior } from 'vue-router'
export const scrollBehavior: RouterScrollBehavior = (
to,
from,
savedPosition,
) => {
// back and forward: put them where they were
if (savedPosition) {
return savedPosition
}
// a link to an anchor on the page
if (to.hash) {
return { el: to.hash, behavior: 'smooth' }
}
// same screen, different parameters: do not jump
if (to.name === from.name) {
return false
}
return { top: 0 }
}Telling the user the chunk is loading
A lazy route's chunk is fetched between beforeEach and afterEach, so on a slow connection the click looks ignored until it arrives. This indicator covers that gap. Register global guards in a component that mounts exactly once, such as App.vue, or in the router module: a component that mounts repeatedly adds a new guard each time. beforeEach and afterEach both return a function that removes the guard, which is the way out if you must register one in a component that remounts.
<script setup lang="ts">
import { ref } from 'vue'
import { RouterView, useRouter } from 'vue-router'
const router = useRouter()
const navigating = ref(false)
// App.vue mounts once, so these guards are registered once
router.beforeEach(() => {
navigating.value = true
})
router.afterEach(() => {
navigating.value = false
})
</script>
<template>
<div>
<p v-if="navigating" role="status">Loading</p>
<router-view />
</div>
</template>
<style scoped>
[role='status'] {
font-size: 0.875rem;
}
</style>
Discussion