rogue

Routing

File-system routing à la Astro / SvelteKit / Next: every file under src/pages/ becomes a route, and the folder structure determines URL shape, layouts, and 404 / error handling.

How files become URLs

The mapping is mechanical — no config file, no manual route table. The rogueRouter Vite plugin scans src/pages/ once at startup (and again on filesystem changes) and emits a manifest:

src/pages/index.jsx         → /
src/pages/about.jsx         → /about
src/pages/blog/index.jsx    → /blog
src/pages/blog/[slug].jsx   → /blog/:slug   (params.slug = "hello-world")
src/pages/[...rest].jsx     → catch-all     (params.rest = "deep/nested/path")
src/pages/_layout.jsx       → wraps every page in this folder + below
src/pages/_404.jsx          → unmatched URLs
src/pages/_error.jsx        → thrown errors during render or loader

Brackets in filenames are dynamic segments — [slug] matches any single segment and exposes it as params.slug. The triple-dot form [...rest] matches an unlimited number of segments. Anything starting with an underscore is a framework convention and never becomes a route by itself.

Layouts cascade

A _layout.jsx wraps every page in its folder and below. They cascade from root to leaf. Given:

src/pages/
  _layout.jsx              ← root: site shell, nav, footer
  blog/
    _layout.jsx            ← blog: sidebar + breadcrumbs
    post-a.jsx             ← page

Visiting /blog/post-a renders as:

RootLayout
  └── BlogLayout
        └── PostPage

Each layout is a regular function component that takes a children prop and renders it somewhere in its JSX:

// src/pages/_layout.jsx
export default function RootLayout({ children }) {
  return (
    <div class="shell">
      <header>…site header…header>
      <main>{children}main>
    div>
  )
}

Loaders — data for a page

A page can export an async loader that fetches the data it needs. The loader runs server-side during SSR and on every client-side navigation to the route. Whatever it returns becomes the data prop on the page component:

// src/pages/blog/[slug].jsx
export async function loader({ params, url, request }) {
  const post = await fetch(`https://api.example.com/posts/${params.slug}`)
    .then(r => r.json())
  return { post }
}

export default function Post({ data, params }) {
  return (
    <article>
      <h1>{data.post.title}h1>
      <div innerHTML={data.post.body} />
    article>
  )
}

The loader receives three things:

  • params — the dynamic segments from the URL pattern, parsed as strings.
  • url — the current URL path (with query string).
  • request — a WHATWG Request object on the server side. Use this to read headers, cookies, etc. — anything the client needs to be authenticated for happens here, not in the page component.

Loaders are server-only by default

At build time, the framework strips loader bodies from the client bundle. They never run in the browser. On client-side navigation, the router fetches the loader's return value via a JSON request to the same URL, then hands it to the page component. That means it's safe to import a database client, read environment variables, or use server-only secrets inside a loader — none of it leaks to the client.

Reading the current route reactively

useRoute() returns signals for the route's URL, params, query, and loader data. Because they're signals, anything that reads them inside JSX or an effect updates automatically when the route changes:

import { useRoute, effect } from '@jjordy/rogue/router'

const { url, params, query, data } = useRoute()

// In JSX — the rendered text updates on every navigation:
return <div>You're at {url()} with id={params().id}div>

// In an effect — runs on every route change:
effect(() => {
  analytics.track('page_view', { path: url() })
})

Note the parens: url(), params(). They're signal reads, not properties. See Conventions → Reactivity if that looks unfamiliar.

Programmatic navigation

import { navigate } from '@jjordy/rogue/router'

navigate('/blog/hello')                       // pushState, scroll to top
navigate('/login', { replace: true })         // replaceState, no back-entry
navigate('/blog?filter=2025', { scroll: false })  // tab UIs: keep scroll

Default behavior matches what most users expect from SvelteKit / Next / SolidStart: scroll to top on push/replace, restore prior position on back / forward, anchor to #hash if the URL has one. The scroll: false opt-out is useful for tab-switch / filter / infinite-scroll UIs where the user shouldn't lose their place.

Auto-revalidation after a mutation

When a form action's run() succeeds (and doesn't redirect()), the framework re-runs the current page's loader and pushes the fresh data back to the client. The page re-renders with new content automatically — no manual refetch() calls, no useEffect dependency arrays, no SWR cache to invalidate.

That's why a comment form on a blog post can just say "create the comment" in its run() body and the rendered comments list updates on success. The loader that loaded the comments runs again, the new comment is in its return value, and the data signal updates. See Forms + mutations for the full story.

View Transitions

Client-side navigations animate automatically using the browser's View Transitions API . The API is feature-detected — browsers that don't support it (Firefox today) behave exactly as before, with no animation and no errors.

The default cross-fade covers the mount target. Forward navigations (clicks, navigate()) are tagged as forward; back / forward popstate navigations are tagged as back or forward based on the history direction.

Opting out

Pass transition: false to skip the animation for a single navigation — useful for settings changes or state updates that shouldn't animate:

navigate('/settings', { transition: false })

Shared-element transitions

For richer animations, give the same view-transition-name to elements on both the old and new pages. The browser morphs them automatically:

/* Old page — thumbnail */
.card-image { view-transition-name: hero; }

/* New page — full image */
.hero-image { view-transition-name: hero; }

Respecting reduced motion

Users with motion sensitivity can enable prefers-reduced-motion: reduce in their OS. Respect that preference by disabling all view-transition animations:

@media (prefers-reduced-motion: reduce) {
  ::view-transition-old(root),
  ::view-transition-new(root) {
    animation: none !important;
  }
}

This makes navigations instant for users who asked for less motion. The docs site itself ships this pattern — covering root, header, and nav transition names.

Directional animations

The router tags each transition with its direction — forward for link clicks and navigate(), back for browser-back popstate. Use :active-view-transition-type() (Chrome 125+) to reverse the slide on back navigations:

/* Keyframes — subtle 20% slide + fade */
@keyframes slide-out-left {
  to { transform: translateX(-20%); opacity: 0; }
}
@keyframes slide-in-from-right {
  from { transform: translateX(20%); opacity: 0; }
}
@keyframes slide-out-right {
  to { transform: translateX(20%); opacity: 0; }
}
@keyframes slide-in-from-left {
  from { transform: translateX(-20%); opacity: 0; }
}

/* Forward (default) — old content exits left, new enters right */
::view-transition-old(root) {
  animation: slide-out-left 200ms cubic-bezier(0.4, 0, 0.2, 1) both;
}
::view-transition-new(root) {
  animation: slide-in-from-right 200ms cubic-bezier(0.4, 0, 0.2, 1) both;
}

/* Back — reverse the direction */
:active-view-transition-type(back) {
  &::view-transition-old(root) {
    animation-name: slide-out-right;
  }
  &::view-transition-new(root) {
    animation-name: slide-in-from-left;
  }
}

To keep persistent chrome (header, sidebar) from sliding, give those elements their own view-transition-name. The browser captures them separately and holds them still while the main content animates:

header { view-transition-name: header; }
aside  { view-transition-name: nav; }

/* Suppress the default cross-fade on the static elements */
::view-transition-old(header),
::view-transition-new(header),
::view-transition-old(nav),
::view-transition-new(nav) {
  animation: none;
}

Specificity and ordering

When multiple patterns could match the same URL — say, /blog/feed against both /blog/[slug] and a literal /blog/feed file — the build sorts patterns by specificity:

  • Static segments beat dynamic ones.
  • Dynamic segments beat catch-all.
  • Longer patterns beat shorter equally-specific ones.

So blog/feed.jsx wins over blog/[slug].jsx for the URL /blog/feed. You write whichever order feels natural in src/pages/; the build resolves ambiguity for you.

Link prefetching

Client-side navigation feels instant because the router speculatively warms both the loader-data JSON endpoint and the route's JavaScript chunk for any internal link that becomes visible in the viewport. No author code is required — prefetching attaches automatically when mount() runs.

How it works

An IntersectionObserver watches every internal inside the mounted target. When a link stays visible for 300ms (filtering out fast scrolls), the router fires two speculative loads:

  • fetch(href, { headers: { accept: 'application/json' } }) — warms the loader-data JSON endpoint.
  • import(routeChunkUrl) — warms the route's JS chunk via the manifest's load thunk.

Each URL is prefetched at most once per page session. Subsequent observations are no-ops.

Opting out

Add data-no-prefetch to any link that shouldn't be speculatively loaded — useful for logout links or pages with expensive loaders:

<a href="/logout" data-no-prefetch>Sign outa>
<a href="/reports/heavy" data-no-prefetch>Generate reporta>

Data Saver

When navigator.connection?.saveData is true (the user has Data Saver enabled), the entire prefetch observer is skipped. No speculative network requests are made.

Why "visible + 300ms" over hover or viewport-immediate?

Hover-based prefetching fires too late on mobile (no hover state) and too aggressively on desktop (fast mouse sweeps trigger many requests). Viewport-immediate fires too eagerly on long pages — a 200-link directory page would issue 200 requests. The 300ms visibility threshold is the sweet spot: links the user is actually looking at get warmed, while links they scroll past quickly don't waste bandwidth.

Under the hood

The Vite plugin rogueRouter walks src/pages/ at dev-server start (and after each filesystem change). It produces:

  • A virtual module virtual:jsx-wc/routes — the runtime imports this and gets a sorted array of patterns plus lazy-loaded page modules. Each entry has its own dynamic import() so routes code-split automatically.
  • Generated TypeScript types per page — see Conventions → Per-page types.
  • A versioned manifest contract. If the producer and consumer versions ever drift (you'd have to deliberately mix versions of the package), the runtime fails loudly at module load instead of crashing on the first click.