React Router Implementation

React Router v6.4+ turns routing into a data layer: a tree of route objects where each node can own its own data loader, mutation action, error boundary, and lazily-loaded component. If you are building a single-page application and need nested layouts, parallel data fetching before paint, and route-level code splitting that actually shrinks your initial bundle, this page walks through the exact configuration that gets you there — with the failure modes that bite teams in production.

← Back to Framework-Specific Routing Patterns

The Problem

A naive React Router setup renders a component, the component mounts, an effect fires, and only then does data fetching begin. Nest three of those layouts and you get a request waterfall: the shell mounts, fetches, renders the next layer, which mounts, fetches, and so on. Time to Interactive balloons, spinners cascade down the page, and every layout shift hurts your Core Web Vitals.

The second failure is bundle bloat. Import every route statically and the user downloads the admin panel, the settings screen, and the reporting dashboard just to view the home page. Without route-level splitting, the entry chunk grows linearly with the application.

The third is correctness during navigation: scroll position is lost, focus is dropped (a screen-reader and keyboard accessibility regression), in-flight requests are not cancelled when the user navigates away, and redirects flash unauthorised content before the guard runs. React Router v6.4+ solves all three when configured deliberately — but only the data router (createBrowserRouter) unlocks loaders, actions, and <Await>. The legacy <BrowserRouter> component tree does not. This is the most consequential decision you will make in the setup, so make it first. The router builds on the browser’s native History API for every client-side transition, so the same constraints around history.state and the popstate event apply here too.

Core API & Primitives

The data router is constructed from an array of RouteObject nodes. The members you will reach for most:

// react-router-dom v6.22 — TypeScript 5.x
import type { RouteObject, LoaderFunctionArgs, ActionFunctionArgs } from 'react-router-dom';

// A route node: path or index, an element to render, optional data hooks,
// optional error boundary, and nested children that render into <Outlet />.
type Route = RouteObject & {
  path?: string;
  index?: boolean;
  element?: React.ReactNode;
  loader?: (args: LoaderFunctionArgs) => Promise<unknown> | unknown;
  action?: (args: ActionFunctionArgs) => Promise<unknown> | unknown;
  errorElement?: React.ReactNode;
  children?: RouteObject[];
};

// LoaderFunctionArgs hands you the matched params, the Request (with an
// AbortSignal), and the route context. Return data or throw a Response.
type LoaderArgs = {
  params: Record<string, string | undefined>;
  request: Request; // request.signal aborts on navigation away
};

The hooks that read this data inside components:

  • useLoaderData<T>() — the resolved return value of the nearest route’s loader.
  • useActionData<T>() — the return value of the most recent action, typically validation results.
  • useNavigate() — programmatic navigation; prefer <Link>/<NavLink>/<Form> for anything declarative.
  • useNavigation() — the global transition state (idle / loading / submitting) for pending UI.
  • redirect(url, init?) — returned or thrown from a loader/action to issue a navigation.

createBrowserRouter accepts the route array plus options such as basename. It relies on window.history and is therefore unavailable in a non-DOM environment without a polyfill such as jsdom.

Step-by-Step Implementation

Prerequisite: React 18.2+ and a bundler that supports dynamic import() (Vite or Webpack 5). Every code block below is TypeScript-first and assumes react-router-dom v6.22.

Step 1: Build the data router with a root layout

Define a root route whose element renders a persistent shell and an <Outlet />. Child routes render into that outlet without remounting the shell.

// react-router-dom v6.22 — TypeScript 5.x
import { createBrowserRouter, RouterProvider, Outlet, type RouteObject } from 'react-router-dom';
import { lazy, Suspense } from 'react';

// Route-level code splitting: these chunks load only when matched.
const Dashboard = lazy(() => import('./routes/Dashboard'));
const Settings = lazy(() => import('./routes/Settings'));

const routes: RouteObject[] = [
  {
    path: '/',
    element: (
      <div className="app-shell">
        <Suspense fallback={<div aria-live="polite">Loading…</div>}>
          <Outlet />
        </Suspense>
      </div>
    ),
    errorElement: <div role="alert">Something went wrong.</div>,
    children: [
      { index: true, element: <Dashboard /> },
      { path: 'settings', element: <Settings /> },
    ],
  },
];

const router = createBrowserRouter(routes, {
  basename: import.meta.env.BASE_URL, // Vite exposes the configured base path here
});

export const AppRouter = () => <RouterProvider router={router} />;

Step 2: Attach loaders for parallel data fetching

A loader runs during the navigation transition, before the route’s element renders. Loaders for all matched routes fire in parallel, collapsing the waterfall into a single round of concurrent requests.

// react-router-dom v6.22 — TypeScript 5.x
import { useLoaderData, Outlet, type LoaderFunctionArgs } from 'react-router-dom';

interface DashboardData {
  metrics: Array<{ id: string; value: number }>;
  user: { name: string };
}

export async function dashboardLoader({ request }: LoaderFunctionArgs) {
  // Both requests fire simultaneously; request.signal cancels them on navigation.
  const [metricsRes, userRes] = await Promise.all([
    fetch('/api/metrics', { headers: { Accept: 'application/json' }, signal: request.signal }),
    fetch('/api/user', { headers: { Accept: 'application/json' }, signal: request.signal }),
  ]);

  // Throwing a Response routes control to the nearest errorElement.
  if (!metricsRes.ok || !userRes.ok) {
    throw new Response('Failed to load dashboard data', { status: 500 });
  }

  return {
    metrics: await metricsRes.json(),
    user: await userRes.json(),
  } satisfies DashboardData;
}

export const DashboardLayout = () => {
  const data = useLoaderData() as DashboardData;
  return (
    <div>
      <header aria-label="User greeting">Welcome, {data.user.name}</header>
      <main>
        <Outlet />
      </main>
    </div>
  );
};

Wire the loader into the route node: { index: true, element: <DashboardLayout />, loader: dashboardLoader }. The element never renders against undefined data, which removes a whole class of loading-state branches from your components.

Step 3: Handle mutations with actions

An action receives the submitted <Form> data and performs the write. Returning a redirect after a successful mutation is the canonical pattern; returning a plain object surfaces validation errors via useActionData.

// react-router-dom v6.22 — TypeScript 5.x
import { Form, redirect, useActionData, type ActionFunctionArgs } from 'react-router-dom';

interface ActionResult {
  error?: string;
}

export async function settingsAction({ request }: ActionFunctionArgs) {
  const form = await request.formData();
  const displayName = String(form.get('displayName') ?? '').trim();

  if (displayName.length < 2) {
    // Returned (not thrown): re-renders the route with these errors.
    return { error: 'Display name must be at least 2 characters.' } satisfies ActionResult;
  }

  await fetch('/api/settings', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({ displayName }),
    signal: request.signal,
  });

  // Throwing or returning redirect issues a fresh navigation + revalidation.
  return redirect('/settings?saved=1');
}

export const SettingsForm = () => {
  const result = useActionData() as ActionResult | undefined;
  return (
    <Form method="post">
      <label htmlFor="displayName">Display name</label>
      <input id="displayName" name="displayName" />
      {result?.error ? <p role="alert">{result.error}</p> : null}
      <button type="submit">Save</button>
    </Form>
  );
};

Step 4: Guard protected routes in the loader

Validate authorisation in the loader, before the protected chunk renders. Throw redirect to bounce unauthenticated users — this prevents a flash of protected content and stops the protected lazy chunk from rendering. For the trade-offs between client guards and other fallback routing strategies, the rule is the same: the client guard is convenience, the server is the boundary.

// react-router-dom v6.22 — TypeScript 5.x
import { redirect, type LoaderFunctionArgs } from 'react-router-dom';
import { lazy, Suspense } from 'react';

function isValidToken(token: string): boolean {
  return token.length > 0; // replace with real validation
}

const AdminPanel = lazy(() => import('./routes/AdminPanel'));

export async function adminGuardLoader({ request }: LoaderFunctionArgs) {
  const cookieHeader = request.headers.get('Cookie') ?? '';
  const token = cookieHeader.match(/auth_token=([^;]+)/)?.[1];

  if (!token || !isValidToken(token)) {
    // Preserve the intended destination for post-login return.
    throw redirect('/login?redirect=/admin');
  }
  return null;
}

export const AdminRoute = () => (
  <Suspense fallback={<div aria-live="polite">Loading…</div>}>
    <AdminPanel />
  </Suspense>
);

Step 5: Restore scroll and focus on navigation

React Router ships a <ScrollRestoration /> component for the common case, but bespoke restoration (per-key positions, focus to a heading) needs a hook. The pattern below saves and restores positions keyed by path, complementing the broader scroll restoration strategies used across SPAs.

// react-router-dom v6.22 — TypeScript 5.x
import { useEffect, useRef } from 'react';
import { useLocation } from 'react-router-dom';

export function useScrollRestoration() {
  const location = useLocation();
  const positions = useRef<Record<string, number>>({});

  useEffect(() => {
    // Restore the saved offset for this path, defaulting to the top.
    window.scrollTo(0, positions.current[location.pathname] ?? 0);

    const save = () => {
      positions.current[location.pathname] = window.scrollY;
    };
    // Capture the offset just before the next transition unmounts this view.
    return () => save();
  }, [location.pathname]);
}

Verification & Testing

A Playwright spec is the most reliable way to confirm that loaders resolve before paint, that lazy chunks are deferred, and that guards redirect. The test below asserts the dashboard greeting is present on first paint (proving the loader ran before render) and that an unauthenticated visit to /admin lands on the login screen.

// @playwright/test v1.42 — TypeScript 5.x
import { test, expect } from '@playwright/test';

test('dashboard renders with loader data on first paint', async ({ page }) => {
  await page.goto('/');
  // No spinner-then-content flash: the greeting is present immediately.
  await expect(page.getByLabel('User greeting')).toBeVisible();
});

test('admin route redirects unauthenticated users', async ({ page }) => {
  await page.context().clearCookies();
  await page.goto('/admin');
  await expect(page).toHaveURL(/\/login\?redirect=\/admin/);
});

test('settings chunk is not in the initial bundle', async ({ page }) => {
  const requested: string[] = [];
  page.on('request', (r) => requested.push(r.url()));
  await page.goto('/');
  // The lazily-imported Settings chunk should not load until navigated to.
  expect(requested.some((u) => /Settings.*\.js/.test(u))).toBe(false);
});

For a quick manual check, open DevTools, throttle the network to Slow 3G, and watch the Network panel: with parallel loaders you should see the route’s requests start together at the same timestamp rather than staggered.

Performance Tuning

  • Defer non-critical data with defer and <Await>. Return a promise for slow data from the loader and stream it in after the shell paints, instead of blocking the whole transition on the slowest request.
  • Split at the route boundary, not the component boundary. A lazy() import per route gives the cleanest chunk graph; the router prefetches the matched chunk during the transition, so the cost is hidden behind navigation latency.
  • Prefetch on intent. <Link prefetch> (or a hover handler that warms the loader) starts data and chunk fetching before the click, cutting perceived latency to near zero on fast connections.
  • Cancel with request.signal. Threading the loader’s AbortSignal into every fetch frees sockets and avoids state updates from stale navigations — measurable as fewer in-flight requests in long sessions.
  • Keep history.state lean. Large serialised payloads slow every pushState; store identifiers and refetch, as covered under pushState and replaceState usage.

Gotchas & Failure Modes

  • Mixing the data router with <BrowserRouter>. Loaders, actions, and <Await> only work under createBrowserRouter + RouterProvider. Inside <BrowserRouter> they silently do nothing.
  • Forgetting to thread request.signal. Loaders without an abort signal leak requests; rapid navigation produces a pile of resolved-but-discarded fetches and occasional out-of-order state.
  • Throwing where you meant to return. A thrown value goes to the nearest errorElement; a returned value goes to the component. Validation errors should be returned, not thrown.
  • Misconfiguring basename for subpath deploys. A wrong basename breaks both asset resolution and route matching. Validate import.meta.env.BASE_URL against the deployment path.
  • SSR hydration mismatches. If a server renders a different route tree than the client constructs, React warns and discards markup. Keep the route definitions identical on both sides.
  • Guards as security. Loader redirects stop the render, not the request. Always enforce authorisation at the API; the client guard only improves the experience.

Go Deeper

  • Migrating from React Router v5 to v6 — the breaking changes (<Switch> to <Routes>, component to element, useHistory to useNavigate) and a staged path off the legacy API onto loaders and actions.