Lesson 33 of 35
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.
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> vs. useNavigate vs. a plain <a>
-
<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 anisActiveflag react-router passes toclassName/styleso 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:
- Matches the route.
- Calls the loader with
{ params: { id: "42" } }. - Waits for the loader's promise (showing the previous page in the meantime, or a pending indicator).
- Renders
<ProfilePage />with the data already inuseLoaderData().
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 aloader(fetch before render), anaction(handle<Form>submits), anerrorElement, and possiblychildren. - 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.errorElementis 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
1.What is
<Outlet />in react-router v7, and why do nested routes need it?2.A route exports
loaderandaction. What do they actually do, and when does each one run?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.What does per-route code splitting —
lazy: () => import("./routes/SettingsPage")on a route definition — actually give you?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 postNo comments yet. Be the first to say something.