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

RSC with Route Features

When using React Server Components as route components, you may also want route features like loaders, typed hooks (useRouteParams, useRouteData), and navigation state. The challenge is that route definitions referencing server components cannot be imported from client modules. This guide shows how to split a route definition into a shared part (importable by client components for type safety) and a server part (where the component is attached), enabling full route features alongside RSC.

The Problem

In an RSC architecture, server modules and client modules cannot freely import from each other. This creates a dilemma for type-safe routing:

  • If routes live in a server module — they can reference server components, but client components cannot import the route objects for type-safe hooks like useRouteParams or useRouteData.
  • If routes live in a shared module — client components can import them, but server components cannot be referenced (importing a server component makes a module server-only).

There is no single location where route objects can both reference server components and be imported by client components.

The Key Insight

The only part of a route definition that is inherently server-specific is the component (because it may be a server component). Everything else — id, path, loader, action, and navigation state — is client-safe. Loaders and actions run in the browser during navigation, so they can live in shared modules.

This means we can split a route definition at exactly one point: the component reference. FUNSTACK Router supports this split through partial route definitions and bindRoute().

Step 1: Define the Route (Shared Module)

Call route() without a component property to create a partial route definition. This object carries all type information and is safe to import from client modules:

// src/pages/user/loader.ts
"use client";
import type { User } from "../../types";

export async function loadUser({ params, signal }) {
  const res = await fetch(`/api/users/${params.userId}`, { signal });
  return res.json() as Promise<User>;
}
// src/pages/user/route.ts — shared module (no "use client" directive)
import { route } from "@funstack/router/server";
import { loadUser } from "./loader";

export const userRoute = route({
  id: "user",
  path: "/:userId",
  loader: loadUser,
});
// Inferred types:
//   Params = { userId: string }  — from path
//   Data   = User                — from loader return type

The id property is required for partial routes — it is used at runtime to match the route context and at the type level to carry type information for hooks.

Step 2: Bind the Component (Server Module)

Use bindRoute() from @funstack/router/server to attach a component to the partial route. This produces a full route definition for <Router />:

// src/App.tsx — Server Component
import { bindRoute } from "@funstack/router/server";
import { Router } from "@funstack/router";
import { userRoute } from "./pages/user/route";
import { UserProfile } from "./pages/user/UserProfile";

const routes = [
  bindRoute(userRoute, {
    component: <UserProfile />,
  }),
];

export default function App() {
  return <Router routes={routes} />;
}

Because bindRoute() lives in the server entry point, the component can be a server component. The resulting route definition is fully compatible with <Router /> — it is the same type as what route() with a component produces.

bindRoute() also accepts optional children, exact, and requireChildren properties in the second argument, just like the regular route() function.

Type-Safe Hooks in Client Components

The partial route object from Step 1 can be imported in client components and passed to hooks for full type safety:

// src/pages/user/UserActions.tsx
"use client";
import { useRouteParams, useRouteData } from "@funstack/router";
import { userRoute } from "./route";

export function UserActions() {
  const { userId } = useRouteParams(userRoute);
  // userId: string ✓

  const user = useRouteData(userRoute);
  // user: User ✓

  return (
    <div>
      <h2>{user.name}</h2>
      <p>User ID: {userId}</p>
    </div>
  );
}

All typed hooks — useRouteParams, useRouteData, and useRouteState — accept both partial route definitions and full route definitions. The type information flows naturally from path patterns, loader return types, and routeState.

Navigation State

routeState() also supports partial route definitions. When called without a component, it produces a partial route carrying the state type:

// src/pages/settings/route.ts — shared module
import { routeState } from "@funstack/router/server";

type SettingsState = { tab: string };

export const settingsRoute = routeState<SettingsState>()({
  id: "settings",
  path: "/settings",
});
// Params = {}, State = { tab: string }
// src/pages/settings/SettingsPanel.tsx
"use client";
import { useRouteState } from "@funstack/router";
import { settingsRoute } from "./route";

export function SettingsPanel() {
  const state = useRouteState(settingsRoute);
  // state: { tab: string } | undefined ✓
  // ...
}

Nested Routes

Partial routes use relative path segments, the same as regular routes. Use bindRoute() with children to build nested route trees:

// src/pages/users/route.ts
import { route } from "@funstack/router/server";
export const usersRoute = route({ id: "users", path: "/users" });

// src/pages/users/profile/route.ts
import { route } from "@funstack/router/server";
import { fetchUser } from "./fetchUser"; // "use client" module
export const userProfileRoute = route({
  id: "userProfile",
  path: "/:userId",       // relative to parent
  loader: fetchUser,
});

// src/pages/users/settings/route.ts
import { route } from "@funstack/router/server";
export const userSettingsRoute = route({
  id: "userSettings",
  path: "/:userId/settings",  // relative to parent
});
// src/App.tsx
const routes = [
  bindRoute(usersRoute, {
    component: <Outlet />,
    children: [
      bindRoute(userProfileRoute, {
        component: <UserProfile />,
      }),
      bindRoute(userSettingsRoute, {
        component: <UserSettings />,
      }),
    ],
  }),
];

For layout routes that don't need typed hooks, id is optional. A route without id can still be used with bindRoute():

import { route, bindRoute } from "@funstack/router/server";
const layout = route({ path: "/dashboard" });
bindRoute(layout, { component: <Outlet />, children: [...] });

Recommended Project Structure

This pattern encourages collocating each route definition with the page components that use it:

src/
  pages/
    user/
      route.ts            ← Step 1: id, path (shared module)
      loader.ts           ← "use client" — loader function
      UserProfile.tsx     ← Server component (the page)
      UserActions.tsx     ← "use client" — imports ./route for hooks
    settings/
      route.ts            ← Step 1: id, path, routeState (shared module)
      Settings.tsx        ← Server component (the page)
      SettingsPanel.tsx   ← "use client" — imports ./route for hooks
  App.tsx                 ← Step 2: bindRoute() assembles route tree

This structure provides several benefits:

  • Locality — The route definition sits next to the components that use it. Imports are short and obvious.
  • Encapsulation — Each page "owns" its route. Adding a new page means adding a folder with a route and components, then one bindRoute() call in App.tsx.
  • Local type safety — Path params and loader data types are defined once in route.ts and consumed by sibling client components. No separate type declarations needed.