FUNSTACK Router

HomeGetting StartedLearnAPI ReferenceExamplesFAQ
GitHub

Built with @funstack/router — A modern React router based on the Navigation API

Learn

Navigation APINested RoutesType SafetyForm ActionsHow Loaders RunError HandlingTransitionsSSRHow SSR WorksStatic Site GenerationSSR with LoadersRSCReact Server ComponentsRSC with Route Features

How SSR Works

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.

Two-Stage Rendering

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 API

Pathless Routes as the App Shell

Pathless 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.

Hooks and SSR

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>;
}

The fallback="static" Mode

When 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" />

Going Beyond the App Shell

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:

  • Static Site Generation — pre-render pages at known paths without running loaders
  • SSR with Loaders — render pages with loader data on the server for fully dynamic SSR

Key Takeaways

  • Only pathless routes without loaders render during SSR (no URL is available on the server)
  • Pathless routes are ideal for app shell markup (headers, footers, layout structure)
  • Avoid 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
  • Once the client hydrates, the real URL from the Navigation API takes over