Adapters

Svelte SPA Router

Connect svelte-spa-router to Ginjou's router contract, including the three pieces the route blocker needs wired into your own Router component.

@ginjou/with-svelte-spa-router implements Ginjou's Router contract on top of svelte-spa-router.

It covers the whole contract, including the optional blocker. Unlike Vue Router, the blocker needs three things wired into your own <Router> — see Wire up the blocker.

Installation

pnpm add @ginjou/with-svelte-spa-router

Setup

Call createRouter() during component initialisation, once, near the app root. It registers its own cleanup with onDestroy.

App.svelte
<script lang="ts">
import { defineRouterContext } from '@ginjou/svelte'
import { createRouter } from '@ginjou/with-svelte-spa-router'
import Router from 'svelte-spa-router'
import { routes } from './routes'

const router = createRouter()
defineRouterContext(router)
</script>

<Router routes={routes} />

That is enough for navigation, current location, and route sync. If any page in your app blocks navigation, wire up the three pieces below as well.

Wire up the blocker

svelte-spa-router has no global hooks. What the blocker needs is a route pre-condition and two <Router> events, and <Router> is rendered by your app, so you pass them in by hand:

App.svelte
<script lang="ts">
import { defineRouterContext } from '@ginjou/svelte'
import { createRouter } from '@ginjou/with-svelte-spa-router'
import Router from 'svelte-spa-router'
import { routes } from './routes'

const router = createRouter()
defineRouterContext(router)

const blockableRoutes = router.withBlocker(routes)
</script>

<Router
    routes={blockableRoutes}
    onRouteLoaded={router.onRouteLoaded}
    onConditionsFailed={router.onConditionsFailed}
/>
PieceWhat it doesLeft out
withBlockerAdds the pre-condition that can hold a navigation.Nothing ever blocks.
onRouteLoadedEnds a navigation that reached its route.An approval lingers.
onConditionsFailedEnds a navigation one of your own route conditions rejected, and takes back the location it was heading for.An approval lingers, and getLocation() reports a route nothing mounted.

Leaving them out is silent: no error, no warning, no type error. If you are upgrading an existing app, the <Router> markup is the part to revisit.

"An approval lingers" means the blocker stays proceeding until the next blocked navigation clears it. On the page being left that is invisible, because the page is gone by then. It shows on a blocker that outlives the navigation, such as a layout or an app shell, where any UI keyed off state !== 'unblocked' stays on screen.

Closing or reloading the tab needs no wiring. While any enabled shouldBlock returns true, the browser shows its own confirmation.

Advanced usage

Nothing below is required. Each part is for a case the shared contract cannot express on its own.

Locations from this adapter carry no meta, because svelte-spa-router has no normalized location object to hand through.

Custom query strings

The default parser and stringifier are built on URLSearchParams, with repeated keys collected into an array. Pass your own to match a backend that expects a different shape:

import qs from 'qs'

const router = createRouter({
    parseQuery: search => qs.parse(search) as any,
    stringifyQuery: query => qs.stringify(query),
})

resolve returns a plain path such as /posts?page=2. Add your own # prefix before putting it in an <a href>.

Your own route conditions

withBlocker returns a new route table with the blocker's pre-condition added to every entry. Your own table is not modified, and options on an already-wrapped route are carried over, including props, userData, and your own conditions.

import { wrap } from 'svelte-spa-router/wrap'

const routes = {
    '/posts': PostList,
    '/admin': wrap({
        component: AdminPage,
        conditions: () => isAdmin(),
    }),
}

const blockableRoutes = router.withBlocker(routes)

The blocker's condition is added first, ahead of your own. svelte-spa-router stops at the first condition that answers false and unmounts what is on screen, so a page with unsaved work has to be asked before anything else can pull the page out from under it.

This means approving is not the same as arriving. Your /admin condition can still reject a navigation the blocker already let through, which is what onConditionsFailed is for.

Your own Router handlers

Both handlers take svelte-spa-router's event detail and return nothing, so call them alongside your own:

<Router
    routes={blockableRoutes}
    onRouteLoaded={(detail) => {
        router.onRouteLoaded(detail)
        track(detail.location)
    }}
    onConditionsFailed={(detail) => {
        router.onConditionsFailed(detail)
        replace('/login')
    }}
/>

The current location is not the hash

A route pre-condition runs after the hash has changed, because that is what tells svelte-spa-router to look for a route at all. So while a navigation is held, the hash already points at the destination while the user is still looking at the old page.

getLocation() therefore reports the last route actually taken, not window.location.hash. A useShow on the page being left keeps its own record id instead of losing it while the user is still deciding.

Copyright © 2026