AniUI Academy

Routing and App Structure

Moving from "one App component with a few children" to a real multi-page app with react-router v7 — route definitions, nested layouts and Outlet, data loaders and actions, Link vs. useNavigate, per-route code splitting, and the error boundary story. Framework alternatives covered in one section so you know when to leave RR.

12 min read

Every lesson so far has treated the app as a single tree — App.jsx with a few children. Real apps have multiple screens, each with its own URL, its own data needs, and its own layout. This lesson covers the practical move from "one component with some state" to a real multi-page app using react-router v7 — the current, stable version — and ends with when you'd leave RR for something like Next or TanStack Router.

The core idea: a router is a decision tree keyed on the URL

A router looks at the current URL and decides which component to render. In react-router v7, you declare that decision tree in one place:

import { createBrowserRouter, RouterProvider } from "react-router";
 
import Root from "./Root";
import Home from "./Home";
import ProfilePage from "./ProfilePage";
import NotFound from "./NotFound";
 
const router = createBrowserRouter([
  {
    path: "/",
    element: <Root />,                 // layout (header, nav, Outlet)
    errorElement: <NotFound />,        // catches anything the layout can't
    children: [
      { index: true, element: <Home /> },
      { path: "profile", element: <ProfilePage /> },
    ],
  },
]);
 
export default function App() {
  return <RouterProvider router={router} />;
}

That's the whole scaffold: one createBrowserRouter call, one <RouterProvider> at the top. Everything else — nested layouts, data, mutations, errors, code splitting — hangs off entries in that tree.

Nested routes and <Outlet />

A parent route's element is the layout that wraps every one of its children. The children render inside an <Outlet /> placeholder:

// Root.jsx
import { Link, Outlet } from "react-router";
 
export default function Root() {
  return (
    <div>
      <header>
        <Link to="/">Home</Link> ·{" "}
        <Link to="/profile">Profile</Link>
      </header>
      <main>
        <Outlet />      {/* active child route renders here */}
      </main>
    </div>
  );
}

On /, the Outlet renders <Home />. On /profile, it renders <ProfilePage />. The header stays mounted across route changes — no remount, no re-fetch of whatever the layout owns.

Nesting goes as deep as the URL does:

{
  path: "dashboard",
  element: <DashboardLayout />,
  children: [
    { index: true, element: <OverviewPanel /> },
    { path: "settings", element: <SettingsPanel /> },
    { path: "team", element: <TeamPanel /> },
  ],
}

DashboardLayout has its own <Outlet /> for the overview / settings / team children. The outer Root's Outlet renders DashboardLayout, and DashboardLayout's Outlet renders the active panel. One file, one layout, three screens.

  • <Link to="..."> — the default. A click is intercepted by the router, matching loaders run, the Outlet swaps. No full-page reload.

  • <NavLink to="..."> — same as Link, plus an isActive flag react-router passes to className / style so you can style the currently-active tab.

  • useNavigate() — the imperative version, for navigation triggered by non-click events (after a form submits, after a timeout, after a WebSocket message):

    const navigate = useNavigate();
    navigate("/dashboard");         // forward navigation
    navigate("/login", { replace: true }); // replace history entry
    navigate(-1);                   // browser back
  • Plain <a href="..."> — reserve for links that leave the app (<a href="https://aniui.dev">) or genuinely want a full reload (a PDF download). Inside the app, a bare <a> throws away your running app state.

Data: loader and action

The lesson on fetching-data-in-effects showed data fetching inside a component's useEffect. That works, and sometimes it's the right tool, but it has a shape problem on a multi-page app: every page starts blank, renders a spinner, fetches its data, re-renders with the data. The URL has already changed before the data is anywhere.

React-router v7 inverts that with loaders:

// routes/ProfilePage.jsx
import { useLoaderData } from "react-router";
import { fetchUser } from "../api";
 
export async function loader({ params }) {
  return fetchUser(params.id);   // runs BEFORE the route renders
}
 
export default function ProfilePage() {
  const user = useLoaderData();  // synchronous read; data is already here
  return <h1>{user.name}</h1>;
}

Wiring it into the route definition:

{
  path: "profile/:id",
  element: <ProfilePage />,
  loader: ProfilePage.loader,
}

When a visitor navigates to /profile/42, the router:

  1. Matches the route.
  2. Calls the loader with { params: { id: "42" } }.
  3. Waits for the loader's promise (showing the previous page in the meantime, or a pending indicator).
  4. Renders <ProfilePage /> with the data already in useLoaderData().

The component is pure UI. It doesn't own a loading state for its primary data — the router owns that.

Mutations with <Form> and action

The corresponding write path uses the router's <Form> component and the route's action:

// routes/ProfilePage.jsx
import { Form, useLoaderData } from "react-router";
import { fetchUser, updateUser } from "../api";
 
export async function loader({ params }) {
  return fetchUser(params.id);
}
 
export async function action({ params, request }) {
  const formData = await request.formData();
  await updateUser(params.id, { name: formData.get("name") });
  return null;              // success = the loader re-runs automatically
}
 
export default function ProfilePage() {
  const user = useLoaderData();
  return (
    <Form method="post">
      <input name="name" defaultValue={user.name} />
      <button type="submit">Save</button>
    </Form>
  );
}

<Form method="post"> intercepts the submit, calls the route's action, and — after the action resolves — automatically re-runs the loaders whose data this route depends on. The new name appears on the next render; you didn't have to orchestrate "mutate, then refetch, then update local state." That's the big ergonomic win over a hand-rolled fetch inside a useEffect.

Per-route code splitting

A large app shouldn't force every visitor to download the JS for every route up front. React-router lets a route declare a lazy module:

{
  path: "admin",
  lazy: () => import("./routes/AdminLayout"),
  children: [
    { path: "users", lazy: () => import("./routes/AdminUsers") },
    { path: "billing", lazy: () => import("./routes/AdminBilling") },
  ],
}

The admin routes (and everything they import — charts, editors, big tables) only download when a user navigates under /admin. The router renders the pending state while the chunk is in flight; the chunk's module is expected to export element, loader, action, etc., the same names it would export inline.

The lazy module can export a clientLoader to run in the browser only: useful for data you want to re-fetch on soft navigation without affecting the initial server render.

Error handling per route

Earlier lessons covered React's <ErrorBoundary> for catching render errors inside a component tree. The router adds a parallel concept scoped to a route:

{
  path: "orders/:id",
  element: <OrderPage />,
  loader: OrderPage.loader,
  errorElement: <OrderError />,
}

If the loader rejects, the action throws, or <OrderPage /> errors during render, the router renders <OrderError /> in that route's slot of the Outlet. The rest of the app — the header, the surrounding layout — stays alive and interactive.

Inside the error element you read what happened:

import { useRouteError, isRouteErrorResponse } from "react-router";
 
export default function OrderError() {
  const error = useRouteError();
  if (isRouteErrorResponse(error)) {
    return <p>{error.status} {error.statusText}</p>; // 404, 403, etc.
  }
  return <p>Something went wrong loading this order.</p>;
}

Put a specific errorElement on routes that can plausibly fail (any route with a loader), and a top-level one on the root layout as a last-resort catch-all for "the app itself broke."

A practical app structure

A sensible layout for a non-trivial app:

src/
  main.jsx              -- RouterProvider + bootstrap
  router.js             -- createBrowserRouter([...])
  routes/
    Root.jsx            -- top layout, header, Outlet, top errorElement
    Home.jsx
    NotFound.jsx
    profile/
      ProfilePage.jsx   -- element + loader + action in one file
      ProfileError.jsx
    dashboard/
      DashboardLayout.jsx
      OverviewPanel.jsx
      SettingsPanel.jsx
  api/
    users.js            -- fetch helpers the loaders call
    orders.js
  components/           -- UI building blocks the routes compose

One route per folder (or per file) with its loader, action, element, and errorElement sitting together. New screens are self-contained; the router file is a flat list that reads like a table of contents.

When you'd leave react-router

React-router v7 covers single-page apps and the client-rendered half of most teams' needs. There are two situations where you'd reach for something else:

  • You want server rendering and streaming by default. Then Next.js (App Router) or Remix (which is now the same team as react-router, building on top of it) do the work of running loaders on the server, streaming HTML down, and hydrating on the client. React-router has an SSR story, but if you want SSR from day one, a framework designed around it is less code to wire up.
  • You want full type-safe routing and a richer query layer out of the box. Then TanStack Router is a strong alternative — it generates typed route objects and integrates natively with TanStack Query for data. The ergonomics are different; the mental model (nested routes, loaders, outlets) is the same.

Both are ports of the same ideas this lesson covered. Learning react-router v7 first means the concepts transfer; the API surface is mostly rename-and-rearrange.

What to remember

  • A router matches the URL to a tree of route definitions. Each route has an element (what to render), optionally a loader (fetch before render), an action (handle <Form> submits), an errorElement, and possibly children.
  • Nested routes let a parent own a shared layout and render the active child inside <Outlet />. The child doesn't restate the layout.
  • Inside the app: <Link> / <NavLink> for most navigation, useNavigate() for imperative, plain <a> only for leaving the app.
  • Loaders put data fetching outside render — the component reads via useLoaderData() and is pure UI. Actions handle writes via <Form>; the router re-runs the affected loaders automatically.
  • lazy: () => import("./route") splits a route and its dependencies into a separate chunk the browser only downloads on navigation.
  • errorElement is the router-scoped error boundary: it catches loader rejections, action throws, and render errors, and renders in the parent's Outlet while the rest of the app stays live.
  • Leave react-router for Next.js / Remix (SSR-first) or TanStack Router (type-safe routing + TanStack Query) when the project actually needs what those give you — the mental model carries over.

Check yourself

5 questions · pass 4/5 to unlock Testing React Components

up to 50
  1. 1.What is <Outlet /> in react-router v7, and why do nested routes need it?

  2. 2.A route exports loader and action. What do they actually do, and when does each one run?

  3. 3.You put <a href="/profile">Profile</a> in a react-router app and click it. The page does a full reload. Why — and what's the fix?

  4. 4.What does per-route code splitting — lazy: () => import("./routes/SettingsPage") on a route definition — actually give you?

  5. 5.Each route can declare an errorElement. What does it do, and how is it different from the <ErrorBoundary> the earlier lesson covered?

5 left to answer

Discussion

Sign in to post
Sign in to join the conversation.Sign in

No comments yet. Be the first to say something.