Vue Router 4.6.0 was published on 14 October 2025. An app on Vue 3.5 that writes a routes array and calls createRouter() runs on it unchanged. Two of its fixes reach your app directly: event handlers on RouterLink now pass strict template type checks, and the scroll position survives a refresh on iOS.
The other news is about the package. Regressions from a new build and a parsing change were fixed in three patch releases by 16 October, so the version to install is 4.6.3. And a new vue-router/experimental export is the first public piece of a new way to match routes. The 4.6.0 release page points to the changelog, which has the full list.
What shipped in 4.6.0
The changelog entry for 4.6.0 lists:
- A rebuilt package. The notes say the build moved to
tsup, though thebuildscript in the published 4.6.0package.jsonrunstsdown. They ask for reports of problems with types or exports that do not resolve. - A new
vue-router/experimentalexport with custom resolvers, still undocumented (previewed below). - A fix for
RouterLinkunderstrictTemplates: event handlers on the component no longer produce a type error. - A fix for scroll restoration on iOS, which no longer relies on
beforeunload. - A types fix for options missing from
RouteLocationGeneric. - Minor performance work in
parseURL.
Event handlers on RouterLink pass strict template checks
This fix is for projects that turn on strict template checking. The option goes under vueCompilerOptions in the tsconfig that includes your .vue files, which is tsconfig.app.json in a create-vue project:
{
"vueCompilerOptions": {
"strictTemplates": true
}
}
With it, vue-tsc rejects any prop or event that a component does not declare. That catches a misspelt prop such as :lable="...", which Vue would otherwise pass through silently as an attribute.
Before 4.6, the type of RouterLink declared only its own props (to, replace and the rest), so a handler on it failed the type check even though it worked in the browser. On 4.5, vue-tsc rejects the @mouseenter in this component, saying that onMouseenter does not exist in the type of RouterLink's props:
<script setup lang="ts">
import { RouterLink } from 'vue-router'
const props = defineProps<{ to: string; label: string }>()
function track() {
console.log(`hovered ${props.label}`)
}
</script>
<template>
<RouterLink :to="props.to" @mouseenter="track">{{ props.label }}</RouterLink>
</template>
4.6 adds the attributes and events of the <a> element to the type of RouterLink, except href, which the link builds from to. The file above type-checks on 4.6. If you had turned strictTemplates off because of this error, you can turn it back on.
Scroll position survives a refresh on iOS
When a page is reloaded, Vue Router saves the scroll position just before the page unloads, so that it can restore it afterwards. That saved position is what scrollBehavior receives as savedPosition after a refresh. Up to 4.5, the router saved it in a beforeunload listener, and iOS Safari does not fire beforeunload. So on an iPhone, a refresh went back to the top of the page.
4.6 saves the position on pagehide, and on visibilitychange when the page becomes hidden, events that iOS Safari does fire. A refresh there now lands where the person was, with no change to your scrollBehavior.
Install 4.6.3: what the patches fixed
4.6.0 had three regressions. Two came from the new build, which left out files that some setups load directly; the third came from the parseURL work. Each got its own patch:
- 4.6.1 (15 October) put back
dist/vue-router.esm-bundler.jsfor wrappers that import that file by its path; the bug report came from a uni-app package. The file now prints a deprecation warning, and the next major removes it. - 4.6.2 (15 October) stopped passing a leading
?to a customparseQuery. On 4.6.0, aparseQueryfunction given tocreateRouter()received?foo=barinstead offoo=bar. - 4.6.3 (16 October) restored
vue-router.esm-browser.jsand its minified twin,vue-router.esm-browser.prod.js. Pages that load the router from a CDN as an ES module use these files: theirprocess.envreferences are removed, so they run without a bundler.
Unless you pass your own parseQuery, none of this touches a Vite project that imports vue-router normally, but it is the reason to install 4.6.3 and skip the three before it.
How to upgrade
One command, then check the version that lands:
npm install vue-router@4
npm ls vue-router
The second line should print 4.6.3 or later. The 4.6 releases declare Vue ^3.5.0 as their peer dependency, where 4.5 accepted anything from Vue 3.2, so a project still on Vue 3.4 should update Vue first. unplugin-vue-router 0.16.0, published the same day as 4.6.0, requires 4.6 in turn.
The notes list no breaking changes. The routes array, useRoute(), useRouter(), navigation guards, scrollBehavior and typed routes through unplugin-vue-router all work as they did. On Vue 3.5, upgrade now: there is nothing to migrate. If you load the router from a CDN, use a wrapper that imports its dist files, or pass a custom parseQuery, test once on 4.6.3 before you deploy: those are the paths the patches touched.
A preview of the new route matcher
The stable router turns your routes array into a list of matchers. Every navigation walks that list, tests the path against each route's regular expression and extracts params as strings. That layer is well tested, and it supports aliases, routes added at runtime, nested children and named views.
The custom resolvers in vue-router/experimental are a rewrite of that layer. The notes name four gains:
- Custom param types. An
idcan be parsed as an integer by the matcher, rather than by every component that reads it. - Matching on query parameters, not only on the path.
- A smaller router runtime. The experimental matcher does not parse path strings such as
/orders/:iditself. It takes ready-made regular expressions and param parsers, which a build tool can generate. The notes say it is meant to be used withunplugin-vue-router, the plugin that generates routes from yourpages/folder. - Faster route matching. The notes give no numbers.
The notes point to an experimental playground in the unplugin-vue-router repository for anyone who wants to look. If you use file-based routing, the notes say the switch will eventually be transparent. If you write routes by hand, nothing changes until the resolver stops being experimental and is documented.
How params are matched today
Custom param types change how params are read, so it helps to be precise about how they work in the stable router, which is what you write today. A param is a path segment that starts with a colon. By default it matches anything up to the next slash and arrives in route.params as a string. You can narrow it with a regular expression in parentheses, mark it optional with ?, or let it repeat with + or *. This router file uses all three:
import { createRouter, createWebHistory } from 'vue-router'
const router = createRouter({
history: createWebHistory(),
routes: [
// /orders/42 matches, /orders/abc does not, so it can fall through to the next route
{ path: '/orders/:id(\\d+)', component: () => import('../pages/OrderPage.vue') },
// /orders/abc shows a search for that text instead of a 404
{ path: '/orders/:query', component: () => import('../pages/OrderSearchPage.vue') },
// /users and /users/7 both match; userId is '' when absent
{ path: '/users/:userId?', component: () => import('../pages/UsersPage.vue') },
// /docs/guide/install matches with chapters = ['guide', 'install']
{ path: '/docs/:chapters+', component: () => import('../pages/DocsPage.vue') },
],
})
export default router
The backslash is doubled because the pattern lives inside a JavaScript string: \\d reaches the router as \d. Repeatable params arrive as an array, and when you navigate to such a route by name you pass an array too.
Two router options change the defaults: strict: true makes a trailing slash significant, and sensitive: true makes matching case-sensitive. Set them on createRouter() for every route, or on a single route record.
The regular expression guarantees the shape of the string, but a param is still a string. The component converts it:
<script setup lang="ts">
import { computed } from 'vue'
import { useRoute } from 'vue-router'
const route = useRoute()
const orderId = computed(() => Number(route.params.id))
</script>
<template>
<h1>Order #{{ orderId }}</h1>
</template>
Every component that reads id repeats that Number() call, and nothing stops a route elsewhere from treating the same param as text. Custom param types are meant to remove that duplication: in the experimental resolver the route parses the param, and the component receives a number. Until then, the conversion is yours, and the regular expression plus a computed() works on every 4.x release.