# How Nested Routes Are Handled in React Router's Data Loading: A Deep Dive into the Source Code

> Explore how React Router handles nested routes and data loading by treating the route hierarchy as a tree and executing loaders in parent-first order. Learn more about merged data maps.

- Repository: [Remix/react-router](https://github.com/remix-run/react-router)
- Tags: deep-dive
- Published: 2026-03-06

---

**React Router processes nested routes by treating the route hierarchy as a tree, matching the URL against all ancestors of the leaf route, then executing each route's loader in parent-first order and merging the results into a single data map keyed by route ID.**

Nested routes are a core feature of modern React Router applications, allowing you to build complex UI layouts where parent routes provide shell components and child routes render specific content. Understanding how data loading works across these boundaries is essential for building performant, error-resilient applications. This article examines the implementation details in the `remix-run/react-router` repository to explain exactly how nested route data loading operates under the hood.

## Building the Route Tree from a Flat Manifest

React Router applications typically define routes as a flat manifest where each route references its parent via a `parentId` field. Before any data loading can occur, the framework must reconstruct the hierarchical tree structure that represents the nested UI relationship.

### Grouping Routes by Parent ID

The transformation begins in [`packages/react-router/lib/server-runtime/routes.ts`](https://github.com/remix-run/react-router/blob/main/packages/react-router/lib/server-runtime/routes.ts) where the `groupRoutesByParentId` function organizes the flat manifest into buckets based on parent relationships:

```typescript
function groupRoutesByParentId(manifest: ServerRouteManifest) {
  let routes: Record<string, Omit<ServerRoute, "children">[]> = {};
  Object.values(manifest).forEach((route) => {
    if (route) {
      let parentId = route.parentId || "";
      if (!routes[parentId]) routes[parentId] = [];
      routes[parentId].push(route);
    }
  });
  return routes;
}

```

This grouping enables efficient tree construction by allowing the algorithm to quickly locate all children of any given route ID.

### Recursive Tree Construction with createRoutes

The `createRoutes` function (lines 47-61 in the same file) recursively attaches children to their parents, producing the nested `ServerRoute[]` structure that both server and client use for matching:

```typescript
export function createRoutes(
  manifest: ServerRouteManifest,
  parentId: string = "",
  routesByParentId: Record<string, Omit<ServerRoute, "children">[]> = groupRoutesByParentId(manifest),
): ServerRoute[] {
  return (routesByParentId[parentId] || []).map((route) => ({
    ...route,
    children: createRoutes(manifest, route.id, routesByParentId),
  }));
}

```

This hierarchical structure is essential because it preserves the parent-child relationships that determine loader execution order and error boundary propagation.

## Transforming the Tree for Data Loading

Once the route tree exists, React Router prepares it for the static handler by converting each route into a data-route object that includes wrapped loader and action functions.

### Creating Static Handler Data Routes

The `createStaticHandlerDataRoutes` function in [`packages/react-router/lib/server-runtime/routes.ts`](https://github.com/remix-run/react-router/blob/main/packages/react-router/lib/server-runtime/routes.ts) (lines 73-60) recursively processes the tree to attach actual data loading logic:

```typescript
export function createStaticHandlerDataRoutes(
  manifest: ServerRouteManifest,
  future: FutureConfig,
  parentId: string = "",
  routesByParentId = groupRoutesByParentId(manifest),
): AgnosticDataRouteObject[] {
  return (routesByParentId[parentId] || []).map((route) => {
    let commonRoute = {
      // …error boundary, id, path, middleware…
      loader: route.module.loader
        ? async (args: RRLoaderFunctionArgs) => {
            // ...handle prerendered data, then call the real loader
            let val = await callRouteHandler(route.module.loader!, args);
            return val;
          }
        : undefined,
      action: route.module.action
        ? (args) => callRouteHandler(route.module.action!, args)
        : undefined,
      // …
    };

    return route.index
      ? { index: true, ...commonRoute }
      : {
          caseSensitive: route.caseSensitive,
          children: createStaticHandlerDataRoutes(
            manifest,
            future,
            route.id,
            routesByParentId,
          ),
          ...commonRoute,
        };
  });
}

```

This transformation ensures that the static handler receives a complete picture of the route hierarchy, including which functions to call for data loading at each level.

## Matching URLs to Nested Routes

When a request arrives, React Router must determine which routes in the hierarchy match the current URL, producing an ordered chain from root to leaf.

### The matchServerRoutes Algorithm

Located in [`packages/react-router/lib/server-runtime/routeMatching.ts`](https://github.com/remix-run/react-router/blob/main/packages/react-router/lib/server-runtime/routeMatching.ts) (lines 11-28), `matchServerRoutes` delegates to the same matching algorithm used on the client:

```typescript
export function matchServerRoutes(
  routes: ServerRoute[],
  pathname: string,
  basename?: string,
): RouteMatch<ServerRoute>[] | null {
  let matches = matchRoutes(
    routes as unknown as AgnosticRouteObject[],
    pathname,
    basename,
  );
  if (!matches) return null;

  return matches.map((match) => ({
    params: match.params,
    pathname: match.pathname,
    route: match.route as unknown as ServerRoute,
  }));
}

```

The resulting `matches` array is ordered from the root route down to the deepest child, which is exactly the sequence required for parent-first loader execution.

## Executing Loaders in Parent-First Order

Once the matching chain is established, React Router invokes each loader sequentially, merging the results into a unified data structure.

### The processLoaderData Implementation

The core logic resides in [`packages/react-router/lib/router/router.ts`](https://github.com/remix-run/react-router/blob/main/packages/react-router/lib/router/router.ts). The function is called around lines 3450-3480:

```typescript
let { loaderData, errors } = processLoaderData(
  state,
  matches,          // ordered route matches (parent → child)
  loaderResults,    // raw results from each loader
  undefined,
  revalidatingFetchers,
  fetcherResults,
);

```

The actual implementation (around lines 6600-6700) walks the matches and assembles the data map:

```typescript
export function processLoaderData(
  state: RouterState,
  matches: AgnosticDataRouteMatch[],
  loaderResults: LoaderResult[],
  // …additional args…
): { loaderData: RouteData; errors: RouteErrorData } {
  let loaderData: RouteData = {};

  // 1️⃣ Walk the matches in order
  matches.forEach((match, index) => {
    let result = loaderResults[index];

    // 2️⃣ Successful loader → store under its route ID
    if (isSuccessfulResult(result)) {
      loaderData[match.route.id] = result.data;
    }

    // 3️⃣ If the loader threw a redirect, bubble it up so the router handles it.
    // 4️⃣ If the loader threw an error, store in `errors` keyed by route ID.
  });

  // 5️⃣ Merge with previously‑existing data so that unchanged parent loaders keep
  //    their values (important for nested routes where only a child reloads).
  return {
    loaderData: mergeLoaderData(state.loaderData, loaderData, matches, errors),
    errors,
  };
}

```

This parent-first execution guarantees that child loaders can rely on ancestor data, while the merging logic ensures that parent data persists when only a child route revalidates.

## Special Cases in Nested Data Loading

React Router handles several edge cases that commonly arise in nested route hierarchies.

### Index Routes and Pathless Layouts

**Index routes** (files named [`_index.tsx`](https://github.com/remix-run/react-router/blob/main/_index.tsx) or with `index: true`) participate in the loader chain even when the URL points to a parent path. According to the source in [`packages/react-router/lib/server-runtime/server.ts`](https://github.com/remix-run/react-router/blob/main/packages/react-router/lib/server-runtime/server.ts) (lines 997-1005), the server includes index routes in the manifest response so the client can navigate without additional round-trips.

**Pathless layout routes** (files like [`_layout.tsx`](https://github.com/remix-run/react-router/blob/main/_layout.tsx) with no `path` property) still receive a route ID and participate in loader execution exactly like standard routes. They can export loaders that run before their children, making them ideal for authentication or data prefetching that applies to an entire subtree.

### Error Boundaries and Redirect Propagation

When a nested loader throws an error, `processLoaderData` records it under the child's route ID. During rendering, React Router walks up the tree to find the nearest ancestor with an `ErrorBoundary` export and renders that boundary with the error.

Redirects work similarly: if a child loader returns a redirect response, the execution stack (handled in `singleFetchLoaders` → `callRouteHandler`) bubbles the redirect up immediately, preventing deeper children from loading and initiating the new navigation.

## Practical Example: Dashboard with Nested Reports

Consider a typical application structure where a dashboard layout loads user data, and a nested reports page loads specific reports:

```tsx
// app/routes/dashboard.tsx
export async function loader({ request }: LoaderFunctionArgs) {
  const user = await fetchUser();          // parent data
  return { user };
}

// app/routes/dashboard.reports.tsx (nested)
export async function loader({ request }: LoaderFunctionArgs) {
  const reports = await fetchReports();    // runs *after* dashboard.loader
  return { reports };
}

// Component usage
export default function Dashboard() {
  const { user } = useLoaderData();               // data from parent
  return (
    <div>
      <h1>Welcome, {user.name}</h1>
      <Outlet />                                    // renders nested route
    </div>
  );
}

// app/routes/dashboard.reports.tsx component
export default function Reports() {
  const { reports } = useLoaderData();            // data from child loader
  return <ReportList items={reports} />;
}

```

Under the hood, the following sequence occurs:

1. `matchServerRoutes` matches the URL against the tree, producing `["root", "dashboard", "dashboard.reports"]`.
2. `createStaticHandlerDataRoutes` has already built a hierarchy where `dashboard.reports` is a child of `dashboard`.
3. On the server or during client navigation, `processLoaderData` executes `dashboard.loader` first, storing the result under the `dashboard` route ID.
4. It then executes `dashboard.reports.loader`, storing that data under the `dashboard.reports` ID.
5. The final `loaderData` map looks like:

```json
{
  "dashboard": { "user": {...} },
  "dashboard.reports": { "reports": [...] }
}

```

6. Each component calls `useLoaderData()`, which automatically selects the entry matching its route ID, providing the correct data slice regardless of nesting depth.

## Summary

- **Tree Construction**: React Router builds a hierarchical route tree from flat manifest objects using `parentId` references in [`packages/react-router/lib/server-runtime/routes.ts`](https://github.com/remix-run/react-router/blob/main/packages/react-router/lib/server-runtime/routes.ts).
- **Parent-First Execution**: Loaders execute sequentially from the root route down to the leaf, ensuring child loaders can depend on ancestor data.
- **Data Merging**: Results are stored in a `loaderData` map keyed by route ID, with `processLoaderData` in [`packages/react-router/lib/router/router.ts`](https://github.com/remix-run/react-router/blob/main/packages/react-router/lib/router/router.ts) handling the merge and preserving parent data during partial revalidation.
- **Unified Algorithm**: The same matching and loading logic runs on both server (SSR) and client, ensuring consistent behavior across rendering environments.
- **Error Handling**: Errors and redirects bubble up the route hierarchy to be caught by the nearest error boundary or handled by the router before deeper loaders execute.

## Frequently Asked Questions

### How does React Router determine which loaders to run for nested routes?

React Router uses the `matchServerRoutes` function in [`packages/react-router/lib/server-runtime/routeMatching.ts`](https://github.com/remix-run/react-router/blob/main/packages/react-router/lib/server-runtime/routeMatching.ts) to match the URL against the route tree, producing an ordered array of matches from root to leaf. The router then iterates through this array in `processLoaderData` (located in [`packages/react-router/lib/router/router.ts`](https://github.com/remix-run/react-router/blob/main/packages/react-router/lib/router/router.ts)), invoking each route's loader in sequence. This guarantees that parent loaders execute before child loaders, creating the parent-first data dependency chain.

### What happens if a parent loader fails in a nested route hierarchy?

When a parent loader throws an error, `processLoaderData` records the error under that route's ID in the errors map. Because the loader chain stops processing successful data for that branch, child routes will not have their loaders invoked—there is no point loading child data if the parent context has failed. During rendering, React Router walks up the route tree to find the nearest ancestor with an `ErrorBoundary` export and renders that boundary with the error, preventing the broken component tree from rendering.

### Can child loaders access data from parent loaders?

Child loaders cannot directly access the return value of parent loaders through function arguments. However, because React Router executes loaders sequentially in parent-first order, you can design your data loading strategy to use the request context or session to pass information. More commonly, the component hierarchy accesses parent data via `useLoaderData()` in the parent component, then passes that data down through React's standard prop drilling or context API to child components. The `loaderData` map structure ensures that each component receives exactly the data slice corresponding to its route ID.

### How do index routes fit into the nested data loading pattern?

Index routes participate in the loader chain exactly like standard child routes, but they activate when the URL path matches the parent exactly (without additional path segments). In [`packages/react-router/lib/server-runtime/server.ts`](https://github.com/remix-run/react-router/blob/main/packages/react-router/lib/server-runtime/server.ts) (lines 997-1005), the server ensures index routes are included in the manifest response so the client can navigate to them without additional round-trips. When loading data for a URL that matches a parent with an index child, React Router includes the index route in the matches array and executes its loader after the parent's loader, merging the results into the `loaderData` map under the index route's unique ID.