Navigation
Link, useNavigate, prefetch on hover, programmatic redirects, and the external/hash escape hatches.
Voltro's router is client-side after first paint. Links update the URL via history.pushState + re-render the matching page, without a full reload. Loader data prefetches on hover so the next page is ready by the time the user clicks.
Typed URLs — the routes builder
<Link to=…> does not take a bare string. Its to prop is a branded
VoltroUrl, minted only by the app's generated routes builder or by
externalUrl(). This makes a typo or a link to a route that doesn't exist
a compile error instead of a dead link at runtime.
The codegen writes a routes builder from your src/pages/** tree. Call
the entry for a pattern with its params to get a typed URL:
import { Link } from '@voltro/web'
import { routes } from './.framework/routes' // generated by `voltro dev`
<Link to={routes['/notes/[id]']({ id: '42' })}>Open note 42</Link>routes['/pattern'](params)→VoltroRouteUrl. Missing/extra params are a type error.withQuery(url, { env: 'prod' })— append a query string, keeps the brand.withHash(url, 'section-3')— append a#hash, keeps the brand.externalUrl('https://example.com')— the escape hatch for anything the codegen can't model: cross-origin,mailto:,tel:, hash-only, or a sibling-app route. A deliberate no-op wrapper so any raw string still has to be opted in at the call site.
import { withQuery, withHash, externalUrl } from '@voltro/web'
<Link to={withQuery(routes['/notes/[id]']({ id: '42' }), { tab: 'comments' })}>Comments</Link>
<Link to={withHash(routes['/docs/[[...slug]]']({ slug: ['routing'] }), 'priority')}>Priority</Link>
<Link to={externalUrl('mailto:hi@x.com')}>Email us</Link><Link>
import { Link } from '@voltro/web'
import { routes } from './.framework/routes'
<Link to={routes['/notes/[id]']({ id: '42' })}>Open note 42</Link>What it does:
- Renders an
<a href="/notes/42">so the link is a real anchor (SEO, right-click → "Open in new tab", screen readers, etc. all just work). - Intercepts plain left-clicks →
history.pushState+ matches the new URL. - Modifier keys + middle-click + external URLs pass through to the browser's native behaviour.
Prefetch on hover
<Link to={routes['/notes/[id]']({ id: '42' })} prefetch>Open note 42</Link>With prefetch, hovering / focusing the link fires the destination's loader in the background. By the time the user actually clicks, useLoaderData() resolves immediately on the new page.
Behaviour:
- Idempotent — multiple hovers fire one loader call, results are cached.
- Cached until used or invalidated — a prefetched result stays in the loader cache and is consumed on the next navigation to that route; it isn't discarded on a timer. It's dropped when the route is invalidated (e.g. an error-boundary reset or a mutation that invalidates the loader's data).
- No effect for
staticpages without loaders (nothing to prefetch).
For "everything on the page is prefetchable", apps usually wire prefetch on every internal link by default. Not much downside — loaders are cheap; the wasted ones are typically empty.
useNavigate
For programmatic navigation:
import { useNavigate } from '@voltro/web'
const SignOutButton = () => {
const navigate = useNavigate()
const onSignOut = async () => {
await fetch('/auth/signout', { method: 'POST' })
navigate('/login')
}
return <button onClick={onSignOut}>Sign out</button>
}Pass a path string. The router updates window.location.pathname + renders the new page.
External + hash URLs
Wrap anything the codegen can't model in externalUrl(). <Link> detects URLs starting with a scheme (http://, https://, mailto:, tel:, #anchor) at runtime and falls back to plain browser navigation; a route URL from the routes builder does SPA navigation.
<Link to={externalUrl('https://example.com')}>External</Link> {/* opens normally */}
<Link to={externalUrl('mailto:hi@x.com')}>Email</Link> {/* mailto: handler */}
<Link to={externalUrl('#section')}>Anchor</Link> {/* in-page scroll */}
<Link to={routes['/dashboard']({})}>Internal</Link> {/* SPA nav */}Active link styling
import { Link, useLocation } from '@voltro/web'
const Nav = () => {
const pathname = useLocation()
return (
<ul>
<li><Link to={routes['/']({})} className={pathname === '/' ? 'active' : ''}>Home</Link></li>
<li><Link to={routes['/about']({})} className={pathname === '/about' ? 'active' : ''}>About</Link></li>
</ul>
)
}For "active if URL starts with prefix" (parent nav highlighting):
className={pathname.startsWith('/dashboard') ? 'active' : ''}Compose this into your own NavLink wrapper with useLocation() + <Link> when you reuse the pattern across many links.
Redirects from a loader
When the loader detects "user should be elsewhere":
import { RedirectError } from '@voltro/web'
export const loader = async ({ headers }) => {
if (!signedIn(headers)) throw new RedirectError('/login?from=/dashboard')
return { /* … */ }
}The framework catches it + emits a 303 with location: /login?from=/dashboard on SSR; on a client navigation it runs navigate(..., { replace: true }) so Back doesn't bounce onto the page that redirected. The default status is 303 (a redirect always lands the browser on a GET of the target); pass { status: 307 | 308 } for a method-preserving redirect. The redirect() helper is throwing sugar — if (!signedIn(headers)) redirect('/login').
For client-side redirects (e.g. after a button click):
const onSubmit = async () => {
await mutate.run({ /* … */ })
navigate('/success')
}Scroll behaviour
By default, the router scrolls to the top on every push navigation. Override per-link:
<Link to={withHash(routes['/long-page']({}), 'section-3')}>Jump to section 3</Link>Hash links scroll to the matching id. Setting <Link to={routes['/foo']({})} replace> replaces the history entry (no back-button entry).
Back/forward scroll restoration
The router restores the previous scroll position on back/forward navigations. It sets history.scrollRestoration = 'manual' and owns restoration itself, saving each entry's scroll offset before you leave it and re-applying it (after the target route paints) when you pop back. This is automatic — no setup. Because the router restores after the loader-gated target paints, the offset lands on the right content even for a page that's still fetching when you click Back.
Push/replace navigations still scroll to top (or to the hash target); only back/forward restores.
Blocking navigation (unsaved changes)
useBlocker holds a pending navigation so you can prompt before the user leaves — the unsaved-changes guard.
import { useBlocker } from '@voltro/web'
function EditForm() {
const [dirty, setDirty] = useState(false)
const blocker = useBlocker(dirty) // block while the form has unsaved edits
return (
<form onChange={() => setDirty(true)}>
{/* …fields… */}
{blocker.blocked && (
<div role="dialog">
Discard unsaved changes?
<button onClick={blocker.retry}>Discard & leave</button>
<button onClick={blocker.reset}>Stay</button>
</div>
)}
</form>
)
}When useBlocker's argument is true (or a predicate returning true) and the user tries to leave — a <Link> click, an intercepted <a>, or an imperative navigate — the navigation is held and the hook returns { blocked: true, to, retry, reset }:
retry()— proceed with the held-back navigation.reset()— cancel it and stay on the page.to— where the user was trying to go (render it in the prompt if you like).
A full-page unload (tab close, reload, typed URL) additionally triggers the browser's native leave prompt while any blocker is active.
Pass a predicate to allow some destinations:
// Block everything except an explicit sign-out.
const blocker = useBlocker(({ to }) => dirty && to !== '/logout')Route announcer (accessibility)
On a full page load a screen reader announces the new page. A client-side SPA navigation swaps the DOM without that announcement — so the router ships a built-in route announcer: a visually-hidden aria-live region that speaks the new page's title (from the route's meta, falling back to the pathname) on every navigation. This is automatic — no setup, nothing to render. Give each route a meta.title and the announcement is meaningful:
export const meta = () => ({ title: 'Team · Acme' })History APIs
navigate takes a path string only — (to: string, opts?: { replace?: boolean }). There is no numeric history overload:
const navigate = useNavigate()
navigate('/foo') // push
navigate('/foo', { replace: true }) // replace the current entryFor history traversal, reach for the browser API directly:
window.history.back() // back
window.history.forward() // forwardReading + writing search params
Read the query string with useSearchParams() — a plain URLSearchParams. It's SSR-aware (on the server it reads the request URL; on the client, window.location.search):
import { useSearchParams } from '@voltro/web'
const tab = useSearchParams().get('tab') ?? 'overview'Write it with useSetSearchParams() — the setter updates the query string on the current pathname (via navigate), so the URL changes and every reader re-renders immediately:
import { useSearchParams, useSetSearchParams } from '@voltro/web'
function Tabs() {
const tab = useSearchParams().get('tab') ?? 'overview'
const setParams = useSetSearchParams()
return (
<nav>
<button onClick={() => setParams({ tab: 'overview' })}>Overview</button>
<button onClick={() => setParams({ tab: 'members' })}>Members</button>
</nav>
)
}The setter takes either an object / URLSearchParams, or an updater that receives the current params:
const setParams = useSetSearchParams()
setParams({ tab: 'members' }) // set the whole query
setParams((p) => { p.set('page', '2'); return p }) // patch one param
setParams({}) // clear the query stringSearch-param writes default to a history replace (a filter/tab tweak shouldn't stack a Back entry per keystroke). Pass { push: true } for a distinct history entry, or { scroll: false } to keep the scroll position:
setParams({ page: '2' }, { push: true })During SSR there is no history to write — read useSearchParams() off the request URL for the first paint and call useSetSearchParams() on the client after hydration.
Prefetching programmatically
import { usePrefetch } from '@voltro/web'
const Card = ({ id }) => {
const prefetch = usePrefetch()
return (
<article onMouseEnter={() => prefetch(`/notes/${id}`)}>
{/* …card body, no Link inside */}
</article>
)
}Useful when the prefetch trigger isn't a <Link> (e.g. an entire card area, where the inner link is buried).
Anti-patterns
<a href>for internal queries. Falls through the SPA — full reload. Use<Link>instead.window.location.href = '/foo'. Same — full reload. UseuseNavigate().prefetchon every link blindly. For cookie-gated loaders that hit DB, hovering 50 nav items can pile up 50 DB queries. Useprefetchfor high-confidence destinations only.
Where to read next
- Loaders & meta — what prefetch warms up
- Islands — for pages where most of the JS is stripped