Capítulo 22 de 24

Chapter 22: Monorepo Setup (Simple)

Core Idea

This example splits a TanStack Router application across a pnpm workspace: a router package owns the router instance, route tree, and route definitions (without components), while an app package supplies the actual React components and attaches them to the routes at startup via route.update(). This lets a router/route-tree "shape" be versioned and shared independently of its UI implementation.

Setup

  • Routing style: file-based, inside the router package (routes/$postId.ts uses createFileRoute, compiled into routeTree.gen.ts by @tanstack/router-plugin); routes are defined without a component, which is attached later from the app package.
  • Key dependencies: @tanstack/react-router, @tanstack/router-plugin, @tanstack/history, redaxios, zod (in router); @router-mono-simple/router and @router-mono-simple/post-feature as workspace:* dependencies (in app)
  • Structure: packages/router/src/router.tsx calls createRouter and re-exports RouterIds for type-safe route id lookups. packages/router/src/routes/$postId.ts defines a route with only a loader, no component. packages/app/src/main.tsx imports the router and a separate post-feature package's components, then wires them onto the route tree at runtime.

Code Example

// packages/router/src/router.tsx
export const router = createRouter({
  routeTree,
  defaultPendingComponent: () => <div>Loading form global pending component...</div>,
  scrollRestoration: true,
})
export type RouterType = typeof router
export type RouterIds = RouteIds<RouterType['routeTree']>

// packages/router/src/routes/$postId.ts
export const Route = createFileRoute('/$postId')({
  loader: ({ params }) => fetchPost(params.postId),
})

// packages/app/src/main.tsx
const routerMap = {
  '/': PostsListComponent,
  '/$postId': PostIdComponent,
  __root__: RootComponent,
} as const satisfies Record<RouterIds, (() => React.ReactElement) | null>

Object.entries(routerMap).forEach(([path, component]) => {
  const foundRoute = router.routesById[path as RouterIds]
  foundRoute.update({ component: component ?? EmptyComponent })
})
  • What it demonstrates: attaching component/errorComponent to routes after the fact with route.update(), keyed by RouterIds (a type derived from the route tree), so the router package stays UI-free and the app package supplies presentation.

Key Takeaways

  1. RouteIds<RouterType['routeTree']> gives a compile-time-checked map of every route id, so routerMap in the consuming app is exhaustive: forgetting a route is a type error.
  2. Keeping loaders in the router package but components in the app package lets multiple apps reuse the same data-fetching/route-tree package with different UIs.
  3. route.update({ component, errorComponent }) is called once at app bootstrap, before RouterProvider renders, this is a one-time wiring step, not a per-render pattern.
  4. Package boundaries are enforced by pnpm workspace:* dependencies (@router-mono-simple/router, @router-mono-simple/post-feature), each with its own package.json and build step (vite build && tsc --noEmit).

Connects To

  • router-monorepo-simple-lazy (ch023): the same router/app split, extended with lazy-loaded components across the package boundary; this chapter is the baseline to compare against.
  • router-monorepo-react-query (ch024): extends this split further by adding a third post-query package that owns TanStack Query options, consumed by the router package's loaders.