Migrating from Vue Router 3 to 4
After reading this you will be able to move a Vue Router 3 route table to version 4 without breaking existing deep links — covering the removed wildcard syntax, the new history factories, the changed guard semantics, and the matching differences that silently alter which route wins.
← Back to Router Migration & Interop
Prerequisites
Core Concept
Vue Router 4 is a rewrite, and its breaking changes fall into two very different categories.
The loud ones fail at startup: new VueRouter() is gone, replaced by createRouter; the mode string is gone, replaced by an explicit history factory; router.app and several other properties no longer exist. These are annoying but safe — the application does not boot until they are fixed.
The quiet ones change behaviour without erroring, and they are the reason a migration needs a deep-link suite. The * wildcard is no longer valid path syntax and must become a named parameter with a custom regex. Route matching became stricter about trailing slashes. router.push now returns a promise that rejects on a redirect or an aborted navigation, so an unhandled rejection appears where none did before. And guards that previously relied on next() being callable multiple times now behave differently.
The one that costs the most in production is the wildcard change, because a table missing its catch-all does not error — it just stops matching unknown paths, so every 404 becomes a blank page with a console warning nobody sees in production.
Implementation
1. Replace the constructor and the mode
// Vue Router 3 — the old shape
import Vue from "vue";
import VueRouter from "vue-router";
Vue.use(VueRouter);
const router = new VueRouter({
mode: "history",
base: "/app/",
routes,
});
// Vue Router 4.3 — explicit history factories
import { createRouter, createWebHistory } from "vue-router";
// createWebHashHistory() for hash mode, createMemoryHistory() for SSR or tests.
const router = createRouter({
// The base that was a separate option is now an argument to the factory.
history: createWebHistory("/app/"),
routes,
});
// app.use(router) replaces Vue.use(VueRouter)
2. Convert every wildcard
This is the change that breaks 404 handling silently. A bare * is no longer a valid path; it becomes a named parameter with an explicit regex and a repeat modifier.
// Vue Router 3
const routes = [
{ path: "*", component: NotFound },
{ path: "/docs/*", component: Docs },
];
// Vue Router 4.3
const routes = [
// :pathMatch is a conventional name; (.*)* makes it match any number of segments.
{ path: "/:pathMatch(.*)*", name: "not-found", component: NotFound },
// A scoped catch-all keeps the prefix and captures the remainder.
{ path: "/docs/:pathMatch(.*)*", component: Docs },
];
// Reading it back: the value is an ARRAY of segments, not a string.
// route.params.pathMatch → ["api", "auth", "tokens"]
const remainder = (route.params.pathMatch as string[]).join("/");
Note the array. Vue Router 4 splits a repeated parameter into segments, unlike the string most other routers hand back — a portability difference worth knowing if you share code with another framework, as catch-all and splat routes sets out.
3. Update guards to return values
next() still works, but returning a value is the supported style and avoids the “called twice” class of bug entirely.
// Vue Router 3
router.beforeEach((to, from, next) => {
if (to.meta.requiresAuth && !isLoggedIn()) next("/login");
else next();
});
// Vue Router 4.3 — return false to abort, a location to redirect, or nothing to continue
router.beforeEach(async (to) => {
if (!to.meta.requiresAuth) return; // continue
if (await isLoggedIn()) return; // continue
return { name: "login", query: { next: to.fullPath } }; // redirect
});
4. Handle rejected navigations
push and replace now reject when a guard redirects or aborts. Unhandled, these surface as noisy console errors in development and unhandled rejections in production.
// TypeScript 5.x — distinguish real failures from expected redirects
import { isNavigationFailure, NavigationFailureType } from "vue-router";
async function go(to: string): Promise<void> {
const failure = await router.push(to);
if (!failure) return;
if (isNavigationFailure(failure, NavigationFailureType.duplicated)) return; // harmless
if (isNavigationFailure(failure, NavigationFailureType.aborted)) return; // a guard said no
reportError(failure); // anything else is worth knowing about
}
5. Decide the trailing-slash policy explicitly
Version 4 matches strictly, so /reports and /reports/ are different routes unless you say otherwise. Every bookmark of the form you no longer match will 404.
// TypeScript 5.x — normalise once, at the top, rather than duplicating routes
router.beforeEach((to) => {
const path = to.path;
if (path !== "/" && path.endsWith("/")) {
return { path: path.slice(0, -1), query: to.query, hash: to.hash, replace: true };
}
});
Verification
// @playwright/test v1.44 — the assertions that catch the quiet changes
import { test, expect } from "@playwright/test";
test("unknown paths still reach the not-found route", async ({ page }) => {
await page.goto("/definitely-not-a-real-page");
await expect(page.getByRole("heading", { name: /not found/i })).toBeVisible();
});
test("both trailing-slash forms resolve", async ({ page }) => {
for (const path of ["/reports", "/reports/"]) {
await page.goto(path);
await expect(page.locator("[data-route='reports']")).toBeVisible();
}
});
test("a nested catch-all captures the whole remainder", async ({ page }) => {
await page.goto("/docs/api/auth/tokens");
await expect(page.locator("[data-doc-path='api/auth/tokens']")).toBeVisible();
});
test("a guard redirect does not produce an unhandled rejection", async ({ page }) => {
const errors: string[] = [];
page.on("pageerror", (e) => errors.push(e.message));
await page.goto("/admin"); // redirects to /login for an anonymous visitor
await expect(page).toHaveURL(/\/login/);
expect(errors).toHaveLength(0);
});
Generate one deep-link test per path in your URL contract rather than writing them by hand. The paths that break are always the ones nobody remembered existed.
Gotchas
path: "*"fails silently. No throw, no match — the 404 route simply stops working, and in production nothing tells you.params.pathMatchis an array in v4. Code that didparams.pathMatch.split("/")now operates on an array and produces nonsense rather than throwing.router.pushrejects on redirect. Awaiting it without handling the failure produces unhandled rejections on every guarded route.- Strict trailing slashes break bookmarks. Decide the policy and normalise centrally; do not duplicate routes to cover both forms.
basemoved into the history factory. Leaving it on the router options is silently ignored, so every path loses its prefix.- Scroll behaviour receives different arguments and must return a position object; the v3 shape resolves to no scrolling at all.
router.matchwas renamedrouter.resolve, and its return shape changed — code that inspectedmatched[0]may need updating.
FAQ
Can I run Vue Router 3 and 4 side by side during the migration? Not within one Vue instance. If you need an incremental path, split the application at the URL level and route whole prefixes to separately mounted apps, as described in router migration and interop.
Does Vue Router 4 work with Vue 2? No. The migration to Vue 3 has to happen first, which usually makes the router change the smaller half of the work.
What replaces router.app? Nothing directly. The router no longer holds a reference to the application instance; inject what you need through the app’s provide/inject or a store instead.
Why did my dynamic route stop matching after adding a static one? Version 4 ranks by specificity rather than purely by declaration order, so a static path now beats a dynamic one at the same position — usually the behaviour you wanted, but it can change which component renders.
Is the alias option still available? Yes, and it is the cleanest way to keep a legacy path working without a redirect — the old URL renders the same route rather than bouncing the user.
Related
- Router Migration & Interop — the URL contract and cutover strategies that frame any router migration.
- Vue Router Configuration — the version 4 route table this migration produces.
- Vue Router Navigation Guards — the guard semantics that changed most between versions.