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 WHATWGRequestobject 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'sloadthunk.
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 dynamicimport()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.