vue-suspense-route
Suspense-aware routing for Vue 3.
A drop-in <SuspenseRouterView> and useSuspenseRoute() that keep the visible page on the route it was rendered for during <Suspense> transitions — inspired by Nuxt's RouteProvider, packaged for plain Vue + Vue Router apps.
npm install vue-suspense-route- Zero dependencies (peer deps:
vue >= 3.3,vue-router >= 4.4) - No spinners by default: the previous page stays fully interactive until the next page's async
setup()has resolved (YouTube-style navigation) - No
undefinedparams, ever: a component sees the route it was rendered for, for its entire lifetime - Layouts persist: nested layouts are keyed by their own route segment, so navigating between sibling pages doesn't remount (or refetch) the layouts above them
- Query is in-page state: query/hash changes never remount — they update the visible page in place, so a
?search=input keeps focus and nothing refetches (opt into reload-on-query per view when you want it) - Writable query state that doesn't glitch:
useSuspenseRouteQuery()(a drop-in for@vueuse/router'suseRouteQuery) reads the frozen route and writes the live one, so a leaving page's filters don't flash to defaults mid-transition - Optional error boundary: a failed page renders your
#errorslot instead of hanging navigation - Typed routes:
useSuspenseRoute('/projects/[projectId]')mirrorsuseRoute's own typing when you use vue-router's typed routes / unplugin-vue-router - No browser globals touched; tree-shakeable, ESM + CJS, TypeScript-first
The problem
When you combine <RouterView> with <Suspense> and pages with async setup():
- Vue Router updates
route.paramsthe instant navigation commits. <Suspense>keeps the old page on screen until the new page's asyncsetup()resolves.- During that window the still-visible old page observes the new params — often
undefined.RouterLinks throw (Missing required param), param-dependent computeds crash, and a thrown error can hang navigation entirely.
Navigation: /projects/proj-1 → /projects
BEFORE: URL=/projects/proj-1 | Visible: ProjectPage | route.params.projectId = 'proj-1'
PENDING: URL=/projects | Visible: ProjectPage (!) | route.params.projectId = undefined ← crash
RESOLVED: URL=/projects | Visible: ProjectsList |
The fix (instance isolation)
<SuspenseRouterView> wraps each page in a RouteProvider keyed per navigation. Every navigation therefore mounts a brand-new provider instance, and <Suspense> retains the previous instance — untouched — until the new page resolves. A retained instance never receives prop updates, so it keeps pointing at exactly the route it was created with.
That retained reference is the freeze: no pending flags, nothing to flush — and therefore no first-render window where stale content can flash. useSuspenseRoute() simply reads the route its nearest provider captured.
Navigation: /projects/proj-1 → /projects
BEFORE: Visible: instance A | useSuspenseRoute().params.projectId = 'proj-1'
PENDING: Visible: instance A | useSuspenseRoute().params.projectId = 'proj-1' ← frozen
(B mounting hidden) | useRoute().params.projectId = undefined ← live route moved on
RESOLVED: Visible: instance B | useSuspenseRoute().params = {}
Quick start
Replace your <RouterView>/<Suspense> combination:
<!-- Before -->
<router-view v-slot="{ Component }">
<Suspense>
<component :is="Component" />
<template #fallback>Loading…</template>
</Suspense>
</router-view>
<!-- After -->
<SuspenseRouterView>
<template #fallback>Loading…</template>
</SuspenseRouterView><script setup lang="ts">
import { SuspenseRouterView } from 'vue-suspense-route'
</script>And in pages/layouts, read the route through the suspense-aware composables:
// Before — crashes during transitions:
import { useRoute } from 'vue-router'
const route = useRoute()
const projectId = computed(() => route.params.projectId) // undefined mid-transition!
// After — stable for the component's lifetime:
import { useSuspenseParam } from 'vue-suspense-route'
const projectId = useSuspenseParam('projectId')Nested layouts nest naturally — each layout that renders children adds its own boundary:
<!-- ProjectLayout.vue -->
<template>
<ProjectHeader :project-id="projectId" />
<SuspenseRouterView />
</template>A child SuspenseRouterView resolves against its parent's (possibly frozen) route, so the whole visible tree stays consistent during a transition.
Keying: what remounts, when
Every page wrapper is keyed, and the key is always scoped to the matched route record — navigating across records always remounts (and therefore always freezes). Within a record, every level — leaf pages and ancestor layouts alike — is keyed by its own interpolated path segment (/projects/:projectId → /projects/proj-1):
- A level only remounts when a param it matches on changes.
- Query and hash are never part of the key. A
?search=/#sectionchange updates the retained instance in place — no remount, no re-run of asyncsetup(), no lost focus, scroll, or layout state. The visible page sees the new query reactively viauseSuspenseRoute().
So for /projects/proj-1/workspaces/a → /projects/proj-1/workspaces/b, only the workspace page remounts; both layouts above it stay mounted and their useSuspenseRoute() follows the new location reactively. And /list?search=a → /list?search=b never remounts the leaf at all — a search input bound to ?search= keeps focus.
route-key — overriding the within-record key
Pass route-key (the equivalent of Nuxt's pageKey) to control remounts within a record:
<!-- Opt into reload-on-query: include the query/hash so the leaf remounts
(and freezes) on query changes — the "load everything, then swap" model -->
<SuspenseRouterView :route-key="route => route.fullPath" />
<!-- Static key: one instance per route record, all changes applied in place -->
<SuspenseRouterView route-key="page" />When the key doesn't change across a navigation, the instance is reused and the route returned by useSuspenseRoute() updates reactively in place — no suspension, no remount. When the key does change (always, across route records), you get the full freeze-and-swap behavior.
API
<SuspenseRouterView>
<SuspenseRouterView
name="default" <!-- RouterView name, for named views -->
:timeout="-1" <!-- Suspense timeout (-1 keeps the current view until ready) -->
:suspensible="true" <!-- Let a parent Suspense await this one -->
:route-key="route => route.fullPath" <!-- Optional: within-record remount key; this opts into reload-on-query (see above) -->
@pending="onPending" <!-- A transition started -->
@resolve="onResolve" <!-- The incoming page resolved (also fires when the error view swaps in) -->
@fallback="onFallback" <!-- The fallback was shown -->
@error="(err, info, reset) => …" <!-- A descendant setup/render threw (always emitted) -->
>
<template #fallback>
<LoadingSpinner />
</template>
<!-- Optional: React-style error boundary. The #error SLOT is what activates
the boundary — with it, a failed page renders this instead of hanging
navigation, and the boundary auto-recovers when its route changes.
Without it, errors propagate normally (to an ancestor boundary or
app.config.errorHandler). -->
<template #error="{ error, info, reset }">
<ErrorState :error="error" @retry="reset" />
</template>
</SuspenseRouterView>Error semantics. @error is a notification: it always fires when a descendant's setup/render throws, whether or not this view is a boundary — safe for telemetry. The #error slot is what makes the view a boundary: the failed page is replaced by the slot content (rendered inside the same Suspense, so suspensible parents resolve normally and isPending ends up false), the error stops propagating, and reset() — or any navigation that changes this view's route — re-attempts.
useSuspenseRoute(name?)
Drop-in replacement for useRoute(). Returns the route the current component was rendered for — stable across Suspense transitions. Outside a SuspenseRouterView it falls back to the live useRoute().
const route = useSuspenseRoute()
// With typed routes (vue-router / unplugin-vue-router), the name argument is
// type-only and the return type is derived from useRoute itself:
const route = useSuspenseRoute('/projects/[projectId]')
route.params.projectId // ← typedThe returned route is a stable wrapper object, not the vue-router route itself. Compare locations by value (
route.fullPath === router.currentRoute.value.fullPath), not by object identity. (Nuxt'suseRoutehas the same property.)
useSuspenseParam(name) / useSuspenseParams(names)
const projectId = useSuspenseParam('projectId')
// ComputedRef<string | undefined> — stable during transitions,
// repeatable params ( /:id+ ) are coerced to their first element
const { projectId, workspaceId } = useSuspenseParams(['projectId', 'workspaceId'])
// each ComputedRef<string | undefined>useSuspenseRouteQuery(name, default?, options?)
Writable, suspense-aware query state — a drop-in for @vueuse/router's useRouteQuery that reads from the frozen route but writes to the live router.
const search = useSuspenseRouteQuery<string>('search', '')
const page = useSuspenseRouteQuery('page', 1, { transform: Number }) // Ref<number>
search.value = 'Kelley' // navigates (router.replace by default), preserving other query paramsWhy it exists: vueuse's useRouteQuery reads the live useRoute(), which the freeze doesn't cover. So when you navigate away from a page that holds query state, the live query empties while the leaving page is still visible, and its filter/input flashes to the default right before the new page mounts:
/suppliers?search=Kelley → /categories (incoming page is async)
useRouteQuery('search') → flips to '' on the still-visible suppliers list ← glitch
useSuspenseRouteQuery('search') → stays 'Kelley' until the suppliers page unmounts ← fixed
It reads through the frozen route (like useSuspenseParam does for path params), so the leaving page stays put. Same-page edits stay fully reactive — a query-only change doesn't alter the page key (query is excluded as of 0.2.0), so the instance is reused and the value updates in place with focus preserved.
- Mirrors vueuse's signature:
name,defaultValue, and{ transform, mode }. Usetransform: Number(or{ get, set }) for typed query state;modeis'replace'(default) or'push'. - Writes spread the live current query, so concurrent params aren't clobbered.
- Outside a
SuspenseRouterViewit falls back to the live route, so it's safe to use anywhere.
Pass an optional type parameter to narrow a required param — a drop-in for vueuse's useRouteParams<string>('id') that removes the as ComputedRef<string> cast at the call site. The default stays string | undefined, so optional params remain honest about being absent mid-transition; you opt into narrowing deliberately:
const fileId = useSuspenseParam<string>('fileId')
// ComputedRef<string> — no cast
// For useSuspenseParams, specify the value type alongside the name union:
const { fileId, folderId } = useSuspenseParams<'fileId' | 'folderId', string>([
'fileId',
'folderId',
])
// each ComputedRef<string>This is a type-level assertion only (like
useRouteParams<T>): it trusts the type you pass and doesn't change the runtime value.
useSuspenseRouteContext()
const {
route, // the stable route (frozen during transitions)
liveRoute, // the actual current route (updates the instant navigation commits)
isPending, // Readonly<Ref<boolean>> — whether a transition is in progress
} = useSuspenseRouteContext()liveRoute + isPending are what you want for chrome that should track navigation immediately — progress bars, breadcrumbs, analytics.
Recipes
Top loading bar (YouTube-style):
<SuspenseRouterView
@pending="bar.start()"
@resolve="bar.finish()"
@error="bar.fail()"
/>Error reporting without per-page try/catch:
<SuspenseRouterView @error="(err, info) => report(err, info)">
<template #error="{ error, reset }">
<ErrorPage :error="error" @retry="reset" />
</template>
</SuspenseRouterView>Telemetry only (no boundary): an @error listener without an #error slot reports the error and lets it propagate — this view never swallows errors into a blank screen.
Guarantees, assumptions & trade-offs
- Assumption: Vue Router replaces the route object on navigation rather than mutating it (true of
vue-router@4; verified by this package's test suite). - Identity:
useSuspenseRoute()returns a stable wrapper, so identity comparisons againstrouter.currentRoute.valueare always false — comparefullPathinstead. - Default: every level is keyed by its own interpolated path segment, so query/hash changes update in place rather than remounting (page identity = path + params, never the query). Opt into reload-on-query with
:route-key="route => route.fullPath". - Depth detection uses vue-router's
viewDepthKey, the same mechanismRouterViewitself uses, so it stays correct even with route records that have no component at intermediate levels. - SSR: the package touches no browser globals; server rendering and hydration behave the way Vue's
<Suspense>does (still flagged experimental by Vue). The freeze itself is only observable client-side, during navigations. - The freeze applies to components rendered under a
SuspenseRouterView. App chrome outside it (nav bars, breadcrumbs) sees the live route — usually what you want.
Comparison with Nuxt
Nuxt's <NuxtPage> solves this same problem with an internal RouteProvider. If you're on Nuxt, use Nuxt. This package extracts the load-bearing parts of that design — record-scoped keyed providers, per-depth layout keys, Suspense retention — for apps on plain Vue + Vue Router, and adds an opt-in error boundary and transition state on top.
Related reading
- Vue docs: Suspense
- RFC discussion: Suspense — some thoughts
- vuejs/core#6811 — suspended component still triggers watchers
- nuxt/nuxt#21340 — useRoute providing incorrect data in layout