The router on the server
The router works during server rendering with no special API — what changes is only which lifecycle runs.
The URL comes from the request
Router reads window.location at startup, so the server points its DOM at the
request URL before rendering. A static build does the same, with pushState between
pages:
for (const path of paths) {
window.history.pushState(null, "", path);
const page = await renderPage(<App />);
write(path, renderDocument(page));
}
There is no server-only router API — the same code renders on both sides.
The browser's URL wins on hydration
The route the server rendered travels to the browser in the hydration data, and then the router re-reads the actual URL as it starts up. That last step matters: if the browser is at a different URL than the server rendered — a cached page, a CDN serving one file for many paths, a redirect between request and hydration — re-reading is what makes it show the right page instead of the server's.
Route guards and redirects
A guard is navigation from a lifecycle method — check something, and if the visitor
should be somewhere else, send them. But the guard is only half of it, and the other
half is what render() does meanwhile:
import { Navigator } from "./routes";
class Account extends Component {
private route = this.use(Navigator);
private session = this.use(SessionConsumer);
@mounted
@updated
guard() {
if (this.session.status === "out") this.route.replace("/login");
}
render() {
// This still runs, even when the guard has just asked for a redirect.
if (this.session.status !== "in") return null;
return <h1>{this.session.user.name}</h1>;
}
}
SessionConsumer is your own context — one place decides who is
signed in and publishes the answer, and every page reads it. What matters is that reading
it is instant: no await, no request. The next section is why, and it is also why the
guard carries both decorators.
@mounted runs on both sides (it's shared), which is what makes this work on the very
first load. On the client the replace is an ordinary navigation. On the server there
is no history to change and no client to re-render for, so the render instead signals
a redirect: renderToString throws a ServerRedirect, and the server answers with a
302 to /login. The browser then requests /login and gets the right page — rather
than being handed the account page for a URL it isn't allowed to see.
Signed in, signed out, and "not yet"
Auth has three states, and the third is the one that decides whether a guard works.
Most real apps have a moment on startup where the token has not been checked yet — so
user is not a user and not null either, it is unknown.
Treat unknown as its own answer and the page is never seen. Redirect on a definite "no", render your pending state on "not yet", and render the page only on "yes":
import { Navigator } from "./routes";
class Account extends Component {
private route = this.use(Navigator);
private session = this.use(SessionConsumer); // { status: "pending" | "in" | "out" }
@mounted
@updated
guard() {
// Only a definite "out" is a redirect. "pending" decides nothing yet.
if (this.session.status === "out") this.route.replace("/login");
}
render() {
if (this.session.status === "pending") return <p>Checking your session…</p>;
if (this.session.status === "out") return null;
return <h1>{this.session.user.name}</h1>;
}
}
Both decorators, on the one method, and this is the part that is easy to get wrong.
@mounted runs on the first commit and never again. So a guard that only has @mounted asks
its question while the answer is still pending, gets "don't know", and is never asked
again — and when the answer arrives, render() correctly refuses to build the page but
nothing navigates. The visitor sits on a blank page, still on the protected URL. It even
looks like it worked, because the secret is not on screen.
@updated runs after every commit that is not the first. Reading this.session.status
subscribes this component to that key, so the change from pending to out re-renders —
and that is the commit @updated fires on. One method, both lifecycles: the first
decision and every later one.
That also covers the case that has nothing to do with startup: a session can end while
someone is sitting on the page — a token expires, they sign out in another tab. @mounted
alone would never notice.
Trust the guard alone and the page IS seen. This is the version to avoid, and no
amount of batching saves it — the await releases the frame, the browser paints, and the
account page sits there until the answer comes back:
// ✗ The account page is on screen for the whole round trip.
@mounted async guard() {
const ok = await fetch("/api/session").then((r) => r.ok);
if (!ok) this.route.replace("/login");
}
render() {
return <h1>Your account</h1>; // nothing here knows the check is still running
}
The fix is not a faster check. It is to decide before rendering: validate the session
once, high up — on the server for the first load, in one place on the client afterwards —
publish the answer as context, and let both the guard and
render() read it synchronously. render() then always has an answer, even when the
answer is "not yet".
What still runs, even when the answer is instant
When the answer is instant, nothing is painted — the component is built, the redirect is
applied, and the visitor only ever sees /login. The reason is worth knowing: one
update is one drain, not one render. Someone clicks through to /account, the account
page is built and committed, its @mounted asks for /login, and that redirect is picked
up by the same drain before it returns. Both renders happen inside one microtask, and the
browser paints after microtasks.
But "not painted" is not "not run", and two things follow from the running:
render() has to be safe for a visitor who is not allowed here. It runs before the
redirect lands, so this.session.user.name on a signed-out visitor throws instead of
redirecting — and a thrown render is not a redirect, it is a broken page. That is why the
examples above check status rather than trusting the guard.
Any other @mounted on the component runs too. A fetch("/api/account") in a second
@mounted fires for the visitor you are turning away. Put per-page loading behind the same
answer the guard uses, or accept the wasted request and the 401.
Hydration is the case that picks the lifecycle
@mounted is shared, and on hydration it re-runs on the client. @created is
shared too, but on hydration it is skipped — the server already ran it and the
state it wrote was restored from the page.
That matters here for the exact reason described above: a cached page, or a CDN serving
one file for many paths, can put markup in front of someone the server never checked. A
guard in @mounted fires on that hydration. A guard in a plain @created does not.
So: guard in @mounted. @created runs earlier on a client navigation — before
render() rather than after the commit — but it is silent on the one path where the
browser's answer can differ from the server's.
Your server boundary translates the throw into a response. The SSR starter does this for you; by hand it is:
import { renderToString, ServerRedirect } from "@ramonda/core";
try {
const html = await renderToString(<App />);
// …send the page
} catch (err) {
if (err instanceof ServerRedirect) {
res.statusCode = err.status; // 302
res.setHeader("Location", err.url);
res.end();
} else {
throw err;
}
}
The earliest guard to fire wins, so one redirect decides where the request goes.
A guard routes people; it does not protect data. It decides which page someone lands on. It does not stop the component's code from shipping to the browser, and it cannot stop anyone from calling your API directly — the network tab is right there. Every endpoint has to check for itself. See client / server / shared.
What doesn't run on the server
- The
popstatelistener (a subscription, and subscriptions are client-only — there is no history to react to on the server). - Client-only setup like the single-
Routercheck, keyed to@created({ env: "client" }).
See client / server / shared for how lifecycle picks a side.
Rendering modes per route
A route can be baked at build (static), rendered per request (dynamic), or cached and
refreshed (ISR) — declared server-side with defineServer, checked exhaustively against your
route table. A page that reads the request can't be baked (the build enforces it). See
rendering modes.
Static builds
routePaths(routes) gives you the concrete paths to render, and flags any pattern it
cannot list out. See building a static site.
Next
- Why prerender — and what it buys.