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 static pages 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 */}
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 &amp; 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 entry

For history traversal, reach for the browser API directly:

window.history.back()      // back
window.history.forward()   // forward

Reading + 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 string

Search-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. Use useNavigate().
  • prefetch on every link blindly. For cookie-gated loaders that hit DB, hovering 50 nav items can pile up 50 DB queries. Use prefetch for high-confidence destinations only.