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. On a route whose page declares a searchParams schema, the params type-check against it (below).
  • 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>

Typed withQuery

For a route whose page exports a searchParams schema, the generated builder brands the URL with the schema's decoded shape — through a type-only import, so no page module enters the routes file's value graph and code-splitting stays intact. withQuery then type-checks the params against the page's contract: a misspelt key or a wrong value type is a compile error.

<Link to={withQuery(routes['/notes'](), { page: 2 })}>Page 2</Link>
// withQuery(routes['/notes'](), { pgae: 2 })    → compile error (unknown key)
// withQuery(routes['/notes'](), { page: 'x' })  → compile error (wrong type)

The encode is canonical and schema-free: strings pass through, numbers and booleans via String(), arrays become repeated keys (?tag=a&tag=b), and undefined params are omitted. A Date (or any object) is refused loudly — there is no canonical URL form the type layer could guarantee; declare the field as a string/number transform in the page's searchParams schema and pass that instead. Routes of siblingApps stay untyped — their pages live in another app's compile graph.

<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.

View transitions

Opt in to the browser's View Transitions API for SPA navigations — the browser cross-fades the old and new page (and lets you animate individual elements) with zero animation library:

// app.config.ts
export default {
  type: 'web' as const,
  name: 'MyApp',
  router: {
    viewTransitions: true,
  },
}

With the flag on, every route swap — <Link> clicks, navigate(...), back/forward — runs through document.startViewTransition. Individual navigations override the default in either direction:

navigate('/reports', { transition: false })   // this one swaps plainly
<Link to={routes['/photos/[id]']({ id })} transition>Open</Link>  // this one transitions even when the app default is off

Fallback is exact. In a browser without the API, and for users with prefers-reduced-motion: reduce, navigation behaves precisely as without the flag — same timing, no animation, nothing to feature-detect yourself.

Styling is plain CSS, not a framework DSL. The default is a full-page cross-fade. To animate a specific element independently (the classic shared-element move), give it a view-transition-name and style the browser's pseudo-elements:

.post-cover { view-transition-name: post-cover; }

/* Tune the root cross-fade */
::view-transition-old(root) { animation-duration: 150ms; }
::view-transition-new(root) { animation-duration: 150ms; }

/* The named element morphs between its old and new position */
::view-transition-group(post-cover) { animation-duration: 300ms; }

An element that keeps its view-transition-name across both pages is morphed from its old to its new position automatically — that is the whole shared-element recipe.

Three behaviors worth knowing, all deliberate:

  • defer() fields resolve outside the transition. The transition animates to the committed page — with a deferred field still showing its fallback. The field's later resolution is an ordinary React update, not a second animation. Same rule for an explicit Pending skeleton: the swap to the skeleton is the transition; the settled content arrives un-animated.
  • Rapid navigation skips, never queues. Navigating again while a transition is animating skips the running one (per the API's spec) and the last navigation wins — no queue, no dead time.
  • Overlays and modals do not transition. A view transition snapshots the whole viewport, so running one on an overlay opening would cross-fade the entire page for a change that visually touches one layer. Router view transitions therefore apply to route navigations only; overlay/dialog state changes never trigger one.

Static / multi-page documents: a full-document navigation (between renderMode: 'static' pages, or any MPA link) never goes through the SPA router — opt those into the browser's cross-document transitions with CSS alone, no framework involvement:

@view-transition { navigation: auto; }

Coming from Astro? There is no transition:persist equivalent because none is needed — persistent state lives in a layout, and layouts stay mounted across SPA navigations natively.

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

The recommended way to read the query string is typed: declare the page's query contract as a searchParams schema export and pass that same export to useSearchParams(...):

import { Schema } from 'effect'
import { useSearchParams } from '@voltro/web'

export const searchParams = Schema.Struct({
  tab:  Schema.optionalWith(Schema.String, { default: () => 'overview' }),
  page: Schema.optionalWith(Schema.NumberFromString, { default: () => 1 }),
  tags: Schema.optionalWith(Schema.Array(Schema.String), { default: () => [] }),
})

export default function Notes() {
  const { tab, page, tags } = useSearchParams(searchParams)
  // tab: string · page: number · tags: readonly string[]
}
  • SSR-aware — the same call site decodes the request URL on the server and window.location.search on the client.
  • Total — an invalid query string is never a crash or a 500: the decode falls back to the schema's defaults, exactly like visiting without a query.
  • Every field must be optional or carry a default (Schema.optionalWith(..., { default })). A schema that cannot decode an empty query throws at the first read, naming the fix — that is a definition error, not a runtime input problem.
  • Array fields keep their shape?tag=a&tag=b decodes to ['a', 'b'], and a single ?tag=a decodes to ['a'], not a bare string.

The same schema types links to the route — see typed withQuery above — and the page-export convention itself is documented in Pages → Query strings.

useSearchParams() without an argument stays the raw URLSearchParams — the fallback for routes that declare no schema:

import { useSearchParams } from '@voltro/web'

const tab = useSearchParams().get('tab') ?? 'overview'

Write the query 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.

Typed writes

Pass the page's searchParams schema to get the typed setter. Its object form REPLACES the query — same semantics as the untyped form; a field you leave out decodes to its default on the next read. Its updater form receives the current decoded params, so a merge is one explicit spread — the pagination flip that keeps ?filter stops being a hand-rolled merge:

import { useSetSearchParams } from '@voltro/web'
import { searchParams } from './page'

const setParams = useSetSearchParams(searchParams)
setParams({ page: 2 })                          // replaces → ?page=2 (filter dropped)
setParams((p) => ({ ...p, page: p.page + 1 }))  // keeps ?filter — typed merge

A misspelt key or wrong value type in the object form is a compile error; in the updater, the typed p is the guard (p.pgae does not compile).

For a plain <Link> that keeps the current query, compose the two primitives you already have — decode the current params, spread them into withQuery:

const current = useSearchParams(searchParams)
<Link to={withQuery(routes['/search'](), { ...current, page: current.page + 1 })}>Next</Link>

That composition is also the whole story on retaining params across navigations: there is no implicit retain list — a param survives a navigation only if the link (or setter) encodes it, which keeps every URL self-describing. Spread what must survive; everything else resets to its schema default.

Layout-level schemas are deliberately not a layer of their own: the schema is a page export. A layout (or any co-located component) that needs the same params imports the page's schema and calls useSearchParams(searchParams) with it — composition per schema import, one schema, no drift.

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.