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.
In an RSC architecture, server modules and client modules cannot freely import from each other. This creates a dilemma for type-safe routing:
useRouteParams or useRouteData.There is no single location where route objects can both reference server components and be imported by client components.
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().
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 typeThe 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.
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.
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.
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 ✓
// ...
}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: [...] });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 treeThis structure provides several benefits:
bindRoute() call in App.tsx.route.ts and consumed by sibling client components. No separate type declarations needed.