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>;
}pathlessSSROutletDeferralDuring pathless SSR, an <Outlet /> whose child routes are all path-based renders null on the server, while the same outlet renders content in the browser. React Canary's browser() API offers a cleaner way to express this: the pathlessSSROutletDeferral feature flag makes such outlets call use(browser()) instead, suspending server rendering at the nearest <Suspense> boundary and deferring the outlet content to the browser.
// Opt in via the Router's features prop:
<Router routes={routes} features={{ pathlessSSROutletDeferral: true }} />
// Each outlet that can be unmatched during pathless SSR
// must be wrapped in a Suspense boundary:
function AppShell() {
return (
<div>
<header>My App</header>
<main>
<Suspense fallback={<p>Loading…</p>}>
<Outlet />
</Suspense>
</main>
</div>
);
}The server-rendered HTML then contains the Suspense fallback in place of the outlet, and the browser renders the matched route content there after hydration — without hydration mismatches.
This feature requires a React build that exports browser from react-dom (currently React Canary). On builds without the API, enabling the flag throws an error when the Router renders.
pathlessSSROutletDeferral is opt-in in the current major version and will become the default behavior in the next major version of FUNSTACK Router.
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