In Vue Router 5, a dashboard adds a tenant's feature routes with `router.addRoute()` inside `beforeEach`; why does a deep link to `/reports/q3` still render the 404 page, and what fixes it?
answer
- to was resolved before the route existed
- the catch-all already matched
- redirect so it resolves again
- a string path, not the to object
- add once or loop forever
basics
~20 sto was resolved before the reports route existed, so it matched the catch-all. After addRoute(), return to.fullPath so the URL is resolved again; returning to re-resolves by the catch-all's name. Add the routes once, or it loops.
solid answer
~50 sIn Vue Router 5 `addRoute()` only registers a record; it never re-runs matching for a navigation already in progress. On a deep link, the router resolves `/reports/q3` before any guard runs, and with the tenant routes missing it matches the `/:pathMatch(.*)*` catch-all, so a guard that adds the routes and returns nothing confirms the 404 page. The guard must redirect: return `to.fullPath`, a string, which makes the router resolve the URL again against the new table. Returning the `to` object does not work when the catch-all is named, because the location carries that name and resolves back to it. The guard must also add the routes only once, tracked with a flag or `router.hasRoute()`, so the redirected navigation passes; otherwise it redirects forever, which development builds abort after repeated redirects. Outside a guard, call `router.replace(router.currentRoute.value.fullPath)` after adding.
code
ts · 16 linesimport { router } from './router'
import { loadFeatureModules } from './features'
let routesLoaded = false
router.beforeEach(async (to) => {
const needsFeatures = to.meta.requiresAuth || to.name === 'not-found'
if (routesLoaded || !needsFeatures) return
const modules = await loadFeatureModules()
for (const m of modules) router.addRoute('dashboard', m.route)
routesLoaded = true
// a string is resolved again against the new table; `to` would keep name 'not-found'
return to.fullPath
})go deeper
Recall that router.addRoute adds a route while the app runs, and that the page does not change until the app navigates again.
Explain why a pending navigation keeps its already resolved target, and why a guard must return a location to make the router resolve the URL again.
Show the details that break in production: to.fullPath versus to with a named catch-all, a once-only flag or hasRoute check, and no router.push inside guards.
Decide when routes are registered for a tenant, before the first navigation or lazily in a guard, and how that affects deep links and startup time.
## `addRoute()` only registers A multi-tenant dashboard cannot know its routes at build time: which feature modules exist depends on the tenant's plan and the user's permissions, fetched after sign-in. Vue Router 5 supports this with `router.addRoute()`, which adds a route record to the running router. The documentation stresses the limit: it **only registers** the route. If the new record would match the location the user is already on, or the one a pending navigation is heading to, nothing re-renders until **you navigate again**. ## Why the deep link renders 404 A user opens a bookmarked `/reports/q3`. The app's static table has a home page, a sign-in page and a catch-all `{ path: '/:pathMatch(.*)*', name: 'not-found', component: NotFound }`. The sequence: 1. `app.use(router)` starts the initial navigation, and the router **resolves** `/reports/q3` against the current table; 2. no reports route exists yet, so the location matches `not-found`; 3. `beforeEach` runs, fetches the permissions and calls `addRoute()` for the reports module; 4. the guard returns nothing, so the navigation continues **with the location resolved in step 1**; 5. the not-found page renders, although `/reports/q3` would now match. The route table changed, but the navigation already carried its resolved target. ## The fix: redirect to the same URL Returning a location from a guard starts a new navigation, and a new navigation resolves its target again, now against the updated table. What you return matters: | Guard returns after `addRoute()` | Resolved as | Result | |---|---|---| | nothing | the original resolution | not-found page | | `to.fullPath` | the path, against the new table | reports page, with query and hash kept | | `to` (the object) | by its `name`, `not-found` | not-found page again | The third row surprises people: `to` is a resolved location carrying `name: 'not-found'`, and the matcher resolves a location **by name first** when one is present. A string has no name to follow. ## Avoiding the loop The redirected navigation runs `beforeEach` again. If the guard adds the routes and redirects unconditionally, it redirects forever. Make the second pass fall through: - keep a `routesLoaded` flag, or check `router.hasRoute('reports')`, and skip the work once done; - return the redirect **only** on the pass that actually added routes. Development builds detect a guard that keeps redirecting to the location already being navigated to and abort after more than 30 rounds with a warning; production has no such safety net. ## Adding routes outside a guard The same rule applies when routes are added elsewhere, for example after the permissions request in the sign-in page. `addRoute()` does not touch the current page, so navigate yourself: ```ts for (const route of featureRoutes) router.addRoute('dashboard', route) await router.replace(router.currentRoute.value.fullPath) ``` `router.replace()` avoids a duplicate history entry for the same URL. ## Or register before the first navigation The redirect dance exists because the routes arrive after the first navigation has started. An alternative is to fetch the permissions and register the feature routes **before** installing the router: ```ts const app = createApp(App) app.use(pinia) await installFeatures(await fetchPermissions()) app.use(router) await router.isReady() app.mount('#app') ``` The initial navigation then resolves against the complete table and no guard needs to redirect. The cost is startup time: nothing renders until the permissions request returns, which a signed-out visitor to a public page also pays. Many apps combine both approaches: register eagerly when a session exists, and keep the guard as a fallback for sign-ins that happen later. ## A guard that does it right ```ts let routesLoaded = false router.beforeEach(async (to) => { if (routesLoaded || !to.meta.requiresAuth && to.name !== 'not-found') return const modules = await loadFeatureModules() for (const m of modules) router.addRoute('dashboard', m.route) routesLoaded = true return to.fullPath }) ``` - The first navigation that needs feature routes loads and adds them, then redirects. - The redirected navigation finds `routesLoaded` true and falls through. - A deep link that hit `not-found` is retried once against the full table; if it still matches `not-found`, the page really does not exist. ## Common mistakes - Adding routes in the guard and returning nothing. - Returning `to` instead of `to.fullPath`. - Re-adding routes on every navigation and redirecting forever. - Using `router.push()` inside the guard instead of returning the location, which starts a competing navigation.
- Why is `router.replace()` preferred over `router.push()` after adding routes outside a guard?The user is already on that URL; the navigation only makes the router resolve it again. `router.push()` would add a second history entry for the same URL, so Back would appear to do nothing. `router.replace()` swaps the current entry, and awaiting it tells you when the new page is displayed.
- How does the redirect avoid an infinite loop when the URL really does not exist?The guard redirects only on the pass that added routes; afterwards `routesLoaded` is true and it falls through. The retried navigation to an unknown URL then matches `not-found` again and is confirmed, which is correct: the page does not exist even with the tenant's routes registered.
saying these in an interview costs you the question
- addRoute re-matches the navigation that is currently in progress
- Returning to from the guard re-resolves the URL by path even with a named catch-all
- The guard should call router.push(to.fullPath) and then return true
- Adding the same routes on every navigation is harmless
- Production builds stop an endless guard redirect after 30 rounds