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.

Loud versus quiet breaking changes Two groups of changes. The loud group fails at startup and includes the constructor, the mode option and removed properties. The quiet group changes behaviour silently and includes the removed wildcard syntax, stricter trailing-slash matching, rejected navigation promises and altered guard semantics. Loud — fails at startup safe: the app will not boot until fixed new VueRouter() → createRouter() mode: "history" → createWebHistory() router.app removed router.match() → router.resolve() Quiet — changes behaviour dangerous: nothing errors path: "*" is no longer valid syntax trailing slashes are matched strictly push() rejects on redirect or abort guards: return a value instead of next()
The startup failures are the easy half; the migration's real risk is entirely in the right-hand column.

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 };
  }
});
Wildcard conversion and the value it produces A Vue Router 3 wildcard path is converted to a named parameter with a regex and repeat modifier. The captured value in version 4 is an array of segments rather than the string version 3 produced, so consuming code must join it. URL: /docs/api/auth/tokens Vue Router 3 path: "/docs/*" params.pathMatch = "api/auth/tokens" Vue Router 4 path: "/docs/:pathMatch(.*)*" params.pathMatch = ["api","auth","tokens"] The silent failure A table that keeps path: "*" does not throw — the route simply never matches, so every unknown URL renders nothing at all. In development you get a console warning; in production you get a blank page.
The wildcard rewrite also changes the captured value's type — code that joined a string must now join an array.

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.pathMatch is an array in v4. Code that did params.pathMatch.split("/") now operates on an array and produces nonsense rather than throwing.
  • router.push rejects 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.
  • base moved 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.match was renamed router.resolve, and its return shape changed — code that inspected matched[0] may need updating.
The five silent Vue Router changes by severity Five behaviour changes that do not throw, ranked by consequence: the removed wildcard syntax, strict trailing slash matching, the catch-all value becoming an array, rejected navigation promises, and the base option moving into the history factory. Silent changes ranked by what they cost how it shows up cost if missed path: "*" removed 404 route stops matching every unknown URL renders nothing strict trailing slashes one form 404s half of all bookmarks pathMatch is an array string methods return nonsense broken doc and catch-all pages push() rejects unhandled rejection noise masked real errors in the log base moved into the factory every path loses its prefix the whole app 404s
None of these throw at startup, which is why a contract-generated deep-link suite is the only reliable way to find them.

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.