FUNSTACK Router supports server-side rendering with a two-stage model. During SSR, pathless (layout) routes without loaders render to produce an app shell, while path-based routes and loaders activate only after client hydration.
FUNSTACK Router uses a two-stage rendering model that separates what renders on the server from what renders on the client:
Stage 1 — Server: No URL is available on the server. The router matches only pathless routes (routes without a path property) that do not have a loader. This produces the app shell — layouts, headers, navigation chrome, and other structural markup.
Stage 2 — Client hydration: Once the browser hydrates the page, the actual URL becomes available via the Navigation API. Path-based routes now match, loaders execute, and page-specific content renders.
// What renders at each stage:
// Stage 1 (Server) Stage 2 (Client)
// ─────────────────────────── ─────────────────
// App shell (pathless routes) App shell (pathless)
// ✓ Path routes match
// ✗ No loaders ✓ Loaders execute
// ✗ No URL available ✓ URL from Navigation APIPathless routes (routes without a path property) always match regardless of the current URL. This makes them ideal for defining the SSR app shell — the parts of your UI that should be visible immediately while the rest of the page loads.
Consider the following route tree. During SSR, only the pathless AppShell route renders. The page routes require a URL to match, so they are skipped:
const routes = [
route({
component: AppShell, // Pathless — renders during SSR ✓
children: [
route({ path: "/", component: HomePage }), // Has path — skipped during SSR
route({ path: "/about", component: AboutPage }), // Has path — skipped during SSR
],
}),
];In this example, AppShell might render a header, sidebar, and footer — the structural parts of your application. After hydration, the router matches the actual URL and renders HomePage or AboutPage inside the shell.
Because no URL is available during SSR, hooks that depend on the current URL will throw errors if called during server rendering. The affected hooks are useLocation and useSearchParams.
// These hooks throw during SSR:
useLocation();
// Error: "useLocation: URL is not available during SSR."
useSearchParams();
// Error: "useSearchParams: URL is not available during SSR."To avoid these errors, either use URL-dependent hooks only in components rendered by path-based routes, or read the current path inside a client-side effect (e.g., useLayoutEffect + navigation.currentEntry) so the value is only accessed after hydration:
// ✗ Bad: AppShell renders during SSR, useLocation will throw
function AppShell() {
const location = useLocation(); // Throws during SSR!
return <div>{/* ... */}</div>;
}
// ✓ Good: Read the path in a client-side effect
function useCurrentPath() {
const [path, setPath] = useState<string | undefined>(undefined);
useLayoutEffect(() => {
setPath(navigation.currentEntry?.url
? new URL(navigation.currentEntry.url).pathname
: undefined);
}, []);
return path;
}
function AppShell() {
const path = useCurrentPath(); // undefined during SSR, string after hydration
const isActive = (p: string) => path === p;
return <nav>{/* ... */}</nav>;
}
// ✓ Good: HomePage only renders after hydration (has a path)
function HomePage() {
const location = useLocation(); // Safe — URL is available
return <div>Current path: {location.pathname}</div>;
}fallback="static" ModeWhen the Navigation API is unavailable (e.g., in older browsers), the router's fallback prop controls what happens. With fallback="static", the router reads the current URL from window.location and renders matched routes without navigation interception. Links cause full page reloads (MPA behavior).
This is different from SSR: in static fallback mode, a URL is available (from window.location), so path-based routes match and loaders execute normally. During SSR, no URL is available at all.
import { Router } from "@funstack/router";
// Static fallback: renders routes using window.location
// when Navigation API is unavailable
<Router routes={routes} fallback="static" />The default SSR behavior produces only the app shell. This is perfect for ordinary SPAs where only one HTML page is served and the client takes over all routing. SSR can still be useful in this scenario, normally with a static site generator, to improve perceived performance by showing the shell immediately while the rest of the page loads.
If your server or build tool knows the URL being rendered, you can use the ssr prop to match path-based routes during SSR for richer output:
useLocation and useSearchParams in components that render during SSR; use a client-side effect (e.g., useLayoutEffect) to read location information in the app shell