FullStack Course LogoFullStack Course
Module: React and Ecosystem
React and Ecosystem·106·13 MIN READ

106: React Router — Route Structure, URL State, and Navigation

TOPICS COVERED: React Router — Route Structure, URL State, and Navigation

Learning objectives

You will learn to:

  • understand client-side routing as URL-to-UI state;
  • configure nested routes with current React Router APIs;
  • use layouts and outlets;
  • read path params and search params;
  • model shareable state in the URL;
  • navigate declaratively and imperatively;
  • distinguish Declarative, Data, and Framework modes;
  • avoid storing router objects inside React state.

Why routing is state architecture

When navigation affects what the user sees, the URL is often the most durable place to represent that state. For example:

text
/tasks
/tasks/42
/tasks?status=open&page=3
/settings/profile

These URLs describe different UI states, not merely different server resources. The URL gives those states several useful properties:

  • browser history;
  • bookmarking;
  • deep links;
  • reload persistence;
  • shareability.

That is why route design is also state design. If a value belongs to the URL, duplicating it in a separate global store creates two possible sources of truth. Avoid doing that unless there is a concrete reason, such as a derived cache or a separate workflow model.

Current React Router setup

For a browser application, install the current react-router package:

bash
npm install react-router

A current Data Router setup can look like this:

jsx
import {
  createBrowserRouter,
} from 'react-router';

import {
  RouterProvider,
} from 'react-router/dom';

import {
  createRoot,
} from 'react-dom/client';

const router =
  createBrowserRouter([
    {
      path: '/',
      Component: RootLayout,
      children: [
        {
          index: true,
          Component: HomePage,
        },
        {
          path: 'tasks',
          Component: TaskListPage,
        },
        {
          path: 'tasks/:taskId',
          Component:
            TaskDetailsPage,
        },
      ],
    },
  ]);

createRoot(
  document.getElementById('root'),
).render(
  <RouterProvider
    router={router}
  />,
);

Create the router once, outside the React render tree. Its identity is application infrastructure; it should not be recreated as components render.

Avoid this pattern:

jsx
function App() {
  const [router] = useState(
    () => createBrowserRouter(...),
  );
}

Use it only when an unusual architecture genuinely requires the router to be created there. In the normal case, defining it at module scope is clearer and gives the router a stable lifetime.

Layout routes and Outlet

A layout route can own the page shell while its child route supplies the page-specific content:

jsx
import {
  NavLink,
  Outlet,
} from 'react-router';

function RootLayout() {
  return (
    <>
      <header>
        <nav>
          <NavLink to="/">
            Home
          </NavLink>

          <NavLink to="/tasks">
            Tasks
          </NavLink>
        </nav>
      </header>

      <main>
        <Outlet />
      </main>
    </>
  );
}

<Outlet /> is the insertion point for the child route that matched the current location. Nested child routes render there, so the route hierarchy has a direct relationship to the page structure. The header and navigation remain mounted while the nested page changes.

Params

Consider a route with a dynamic segment:

text
/tasks/:taskId

The segment after /tasks/ is available to the matching component through useParams:

jsx
import {
  useParams,
} from 'react-router';

function TaskDetailsPage() {
  const { taskId } =
    useParams();

  return (
    <h1>
      Task {taskId}
    </h1>
  );
}

Route params are strings, even when they look numeric. Parse and validate them before passing them into domain logic, database queries, or API calls. The component may validate for a useful user experience, but a server must perform its own validation and authorization.

Search params

A task filter belongs in the URL when users should be able to share it, reload it, or return to it with Back and Forward. useSearchParams exposes the current query string and a setter:

jsx
import {
  useSearchParams,
} from 'react-router';

function TaskFilters() {
  const [
    searchParams,
    setSearchParams,
  ] = useSearchParams();

  const status =
    searchParams.get('status')
    ?? 'all';

  function changeStatus(next) {
    setSearchParams((current) => {
      const params =
        new URLSearchParams(
          current,
        );

      if (next === 'all') {
        params.delete('status');
      } else {
        params.set(
          'status',
          next,
        );
      }

      params.delete('page');

      return params;
    });
  }

  return (
    <select
      value={status}
      onChange={(event) =>
        changeStatus(
          event.target.value,
        )
      }
    >
      <option value="all">
        All
      </option>
      <option value="open">
        Open
      </option>
      <option value="done">
        Done
      </option>
    </select>
  );
}

The updater copies the current parameters before changing one concern. It also removes page, because changing a filter should normally return the user to the first page. The resulting state participates in history and deep linking.

Declarative navigation

Use a link when the user is choosing where to go:

jsx
<Link to={`/tasks/${task.id}`}>
  {task.title}
</Link>

A link expresses navigation semantically and retains browser behaviors such as opening the destination in a new tab, copying its address, or using the context menu.

Do not replace every link with a button:

jsx
<button onClick={() => navigate(...)}>

Buttons represent actions. Imperative navigation is appropriate when navigation is the consequence of a workflow, such as moving to a newly created task after a successful save.

useNavigate

For workflow-driven navigation, obtain the navigate function and call it after the operation succeeds:

jsx
const navigate = useNavigate();

async function save() {
  const task = await createTask();

  navigate(
    `/tasks/${task.id}`,
    { replace: true },
  );
}

replace removes the current entry from the meaningful Back path. Use it when returning to the form after the save would not be useful, rather than treating it as a default for every navigation.

Relative routes

Nested routes can navigate relative to their current route:

jsx
<Link to="edit">
  Edit
</Link>

Inside /tasks/:taskId, this can resolve to the nested edit route. Relative links avoid repeating the parent path, but they depend on a clear route hierarchy. Before adding several ../ segments, inspect the nesting; many upward traversals are usually a sign that the structure or ownership is difficult to follow.

Index routes

An index route is the default child rendered at a parent URL:

jsx
{
  path: 'settings',
  Component: SettingsLayout,
  children: [
    {
      index: true,
      Component:
        SettingsOverview,
    },
    {
      path: 'profile',
      Component:
        ProfileSettings,
    },
  ],
}

Here, /settings renders SettingsOverview, while /settings/profile renders ProfileSettings inside the same layout.

Not-found routes

Use a splat route for locations that do not match a more specific route:

jsx
{
  path: '*',
  Component: NotFoundPage,
}

A useful 404 screen should:

  • identify the missing route;
  • preserve global navigation;
  • provide a useful next step.

For server-rendered applications, rendering a 404 screen is not enough. The server must also return the correct HTTP status.

Route organization

Keep the route tree separate from the implementation of every page. For example:

text
src/
├─ app/
│  └─ router.jsx
├─ routes/
│  ├─ RootLayout.jsx
│  ├─ HomePage.jsx
│  └─ tasks/
│     ├─ TaskListPage.jsx
│     └─ TaskDetailsPage.jsx

The exact folders are a team choice. What matters is that route configuration and page ownership remain easy to locate, change, and test.

Router modes

Current React Router documentation describes three modes:

  • Declarative — components such as BrowserRouter, Routes, Route;
  • DatacreateBrowserRouter, loaders, actions, fetchers;
  • Framework — Vite plugin, route modules, SSR/static features and more.

This curriculum starts with Data Mode because it introduces modern loader, action, and error concepts without requiring the full framework runtime.

Common mistakes

Putting all state in URL

Not every temporary value should be shareable. A one-character form draft does not automatically belong in the URL. Ask whether the value represents navigation or a durable view choice before exposing it in an address that users may bookmark.

Putting URL state in Redux

If Back, Forward, and bookmarks should control a value, let the router own that value. A global store can derive information from the URL, but it should not silently become a competing owner.

Use links for navigation and buttons for actions. Replacing a link with a button removes expected browser link behaviors and can also make the interface less clear to assistive technology users.

Recreating router during renders

The router is application infrastructure and should have stable identity. Recreating it during rendering can reset or disrupt router-managed behavior and makes its lifetime harder to reason about.

Exercises

  1. Build /tasks, /tasks/:taskId, and /settings/profile.
  2. Add a root layout with an Outlet.
  3. Move status and page filters into search params.
  4. Add active navigation using NavLink.
  5. Add a not-found route.
  6. Explain when a value belongs in URL state versus component state.

Exit questions

  1. Why is the URL a state owner?
  2. What does <Outlet> do?
  3. What is an index route?
  4. When should you use a Link instead of navigate?
  5. Why should a Data Router be created outside the React tree?
  6. What is the difference between Declarative, Data, and Framework modes?

Official references


Deep dive: route design should mirror product information architecture

A route tree is more than technical configuration. It is a compact description of how the product is organized for the user.

text
/
├─ tasks
│  ├─ index
│  ├─ :taskId
│  │  └─ edit
│  └─ new
└─ settings
   ├─ profile
   └─ notifications

This structure tells you:

  • URL structure;
  • layout nesting;
  • data boundaries;
  • error boundaries;
  • navigation hierarchy.

When pages share meaningful layout or data context, represent that relationship in the route tree. A flat list of unrelated routes hides those relationships and usually makes shared behavior harder to maintain.

Route object example

The same information architecture can be expressed with nested route objects:

jsx
const router = createBrowserRouter([
  {
    path: '/',
    Component: RootLayout,
    children: [
      {
        index: true,
        Component: HomePage,
      },
      {
        path: 'tasks',
        Component: TasksLayout,
        children: [
          {
            index: true,
            Component: TaskListPage,
          },
          {
            path: 'new',
            Component: NewTaskPage,
          },
          {
            path: ':taskId',
            Component: TaskDetailsPage,
          },
          {
            path: ':taskId/edit',
            Component: EditTaskPage,
          },
        ],
      },
    ],
  },
]);

Route params are untrusted strings

The component still reads the dynamic value in the same way:

jsx
const { taskId } = useParams();

The value may be any string accepted by the URL and matching route, for example:

text
"123"
"abc"
"../../../"
very long string

Do not infer validity from the fact that the value came through the router. Client-side validation can produce a better error message or prevent an unnecessary request. The server must validate and authorize independently before accessing a record; a caller can bypass the client router entirely.

Search params as durable UI state

Pagination and filters are often a good fit for query parameters:

text
/tasks?status=open&page=3&sort=due

The resulting state is:

  • shareable;
  • reload-safe;
  • back/forward;
  • linkable;
  • testable.

Query values are still untrusted input. Normalize them before using them:

jsx
const status = ['all', 'open', 'done'].includes(rawStatus)
  ? rawStatus
  : 'all';

For an invalid value, choose deliberately whether to:

  • silently normalize;
  • redirect to a canonical URL;
  • display error.

The right choice depends on whether the invalid URL is harmless, should be corrected for sharing and analytics, or indicates a user-visible problem.

Multi-value params

URLSearchParams already supports repeated keys:

text
/tasks?tag=frontend&tag=urgent

Read them with getAll:

jsx
const tags = searchParams.getAll('tag');

Do not force every filter through a custom comma-delimited format when repeated query keys model the data cleanly. A standard representation is easier to inspect, link, and parse consistently.

Navigation can also carry transient state without putting it in the address:

jsx
navigate('/tasks', {
  state: {
    notice: 'Task created',
  },
});

This is not durable or bookmarkable URL state. It is suitable for genuinely transient navigation context, such as a notice shown after a workflow. Do not use it for critical entity identity or data that must survive a refresh; a page refresh may lose or reinterpret it.

Within this route:

text
/tasks/:taskId

a relative link can target the edit child directly:

jsx
<Link to="edit">Edit</Link>

That is often more maintainable than reconstructing absolute URLs in every component. However, overuse of ../../.. makes the hierarchy harder to understand. Treat that pattern as feedback about route ownership, not just as a string to simplify.

Active navigation

NavLink can expose active and pending state:

jsx
<NavLink
  to="/tasks"
  className={({ isActive }) =>
    isActive ? 'nav-link active' : 'nav-link'
  }
>
  Tasks
</NavLink>

Use the semantic link state and its aria-current support instead of storing an "active menu item" separately in React state. The URL already owns that fact.

Scroll and focus behavior

Single-page navigation does not automatically reproduce every behavior of a full-page browser navigation. Consider:

  • scroll restoration;
  • focus after route change;
  • document title;
  • announcement of new page context.

Router or framework tooling can help with some of these behaviors, but accessibility still requires deliberate testing. A page that visually changed while keyboard and screen-reader users retain stale context is not finished.

Route protection patterns

A client loader can redirect an unauthenticated user:

jsx
async function accountLoader() {
  const user = await getCurrentUser();

  if (!user) {
    throw redirect('/login');
  }

  return { user };
}

This improves the user experience by avoiding a page the user cannot use. It is not a security boundary.

The API must authorize each protected operation independently:

text
DELETE /api/tasks/42

That endpoint must verify the caller's permission even if the UI hides the page or the loader redirects. A user can skip the router entirely and send the request directly.

Route code splitting

Large route modules can be lazy-loaded so rare page code does not have to be part of the initial bundle. Measure the resulting chunks and loading behavior rather than assuming that every split is beneficial.

Do not split a 2 KB route into many extra requests without evidence that the trade-off helps the application.

Route configuration location

Keep routing infrastructure stable and testable. Without conventions, dozens of components can gradually accumulate slightly different path construction:

jsx
navigate('/tasks/' + id + '/edit')

You may centralize URL builders:

jsx
const taskRoutes = {
  details(id) {
    return `/tasks/${encodeURIComponent(id)}`;
  },
  edit(id) {
    return `/tasks/${encodeURIComponent(id)}/edit`;
  },
};

Encoding at the boundary keeps an ID from accidentally changing the path shape. Central builders are especially helpful when the URL structure changes later.

BrowserRouter versus Data Router

Declarative BrowserRouter is a reasonable choice for simpler client-side navigation.

Data Router becomes useful when route definitions should own:

  • loaders;
  • actions;
  • fetchers;
  • error boundaries;
  • pending navigation.

This course moves to Data Router because later server and data concepts depend on those responsibilities.

Failure clinic

Duplicate filter ownership

Suppose the URL says:

text
status=open

while a React global store says:

text
status=done

Which value should the list display? There is no reliable answer until ownership is defined. Pick one owner. If the filter is URL state, derive the displayed value from the URL and make the store respond to it rather than competing with it.

Button used for navigation

Using a button for a destination breaks expected link behaviors, including opening the destination in a new tab and copying its address.

Auth-only hidden page

Hiding a page in the client does not protect its API. The API remains exposed unless it performs server-side authorization.

Query param parsing without defaults

This expression is not sufficient validation:

jsx
Number(searchParams.get('page'))

It can produce 0, NaN, a negative number, or an unreasonably large page. Apply defaults, bounds, and an integer check before using the result.

Deep-dive exercises

  1. Draw a route tree before writing configuration.
  2. Build nested task routes with layout + Outlet.
  3. Model filter/page in search params.
  4. Normalize invalid page and status.
  5. Add active navigation.
  6. Add a client auth redirect and separately describe server authorization.
  7. Test deep link by loading a nested route directly.

Mastery check

Explain:

  • URL as state owner;
  • path params versus search params;
  • route nesting;
  • relative links;
  • client route protection versus server authorization;
  • Data Router motivation.

Production case study: search, pagination, and modal state in the URL

Suppose the product needs this URL:

text
/tasks?status=open&q=invoice&page=2&task=t42

It means:

  • open tasks;
  • search invoice;
  • page 2;
  • details panel for task t42.

With these decisions, the entire workspace can be deep-linkable. A teammate can open the same list, search, page, and selected details panel from a single address.

Canonical parsing

Put parsing rules in one place so rendering, loaders, and tests agree about defaults and invalid input:

jsx
function parseTaskSearch(searchParams) {
  const rawPage = Number(searchParams.get('page') ?? '1');

  return {
    status: ['all', 'open', 'done'].includes(searchParams.get('status'))
      ? searchParams.get('status')
      : 'all',

    query: searchParams.get('q') ?? '',

    page: Number.isInteger(rawPage) && rawPage > 0
      ? rawPage
      : 1,

    taskId: searchParams.get('task'),
  };
}

The parser converts the page to a number, supplies a default, constrains it to a positive integer, and supplies a safe default for the status. It leaves the selected task as the parsed string; code that loads that task must still validate it and handle a missing or unauthorized record.

Update one concern without deleting others

When changing one query value, copy the existing parameters and preserve the rest:

jsx
setSearchParams((current) => {
  const next = new URLSearchParams(current);

  next.set('status', status);
  next.delete('page');

  return next;
});

Do not do this:

jsx
setSearchParams({ status });

if that unintentionally deletes the search text and selected task. The short form is only safe when replacing the complete query state is intentional.

Should modal state live in URL?

A details panel is a good URL candidate if it should be:

  • shareable;
  • Back-button aware;
  • refresh-safe.

For those semantics, putting the selected task in the URL may be appropriate. A transient "Are you sure?" confirmation usually belongs in local state instead. State ownership is driven by user semantics, not by whether the UI happens to look like a modal.


Additional depth: navigation blockers, scroll restoration, and route UX

Unsaved changes

An edit route with a dirty form may need a navigation blocker. Do not indiscriminately block every navigation. Define the behavior first:

text
What counts as dirty?
Does autosave remove need?
Should browser refresh warn?
Should internal navigation show custom dialog?
What if save is pending?

React Router exposes APIs for navigation blocking in appropriate modes. Treat a blocker as UX safety, not as data persistence. It should give the user a chance to avoid losing work, but it cannot be the only copy of that work.

For long forms, server-side draft autosave may be a better solution because navigation then does not threaten the user's progress.

Scroll restoration

SPA navigation can leave the user at an unexpected scroll position. Router or framework tooling can restore scroll based on navigation history, but the intended behavior needs to be tested.

For example:

text
list scrolled to row 80
open details
Back

Should the user return to the previous list scroll position? Usually yes. Do not call this on every route change without considering Back navigation:

jsx
window.scrollTo(0, 0)

Resetting unconditionally can make a normal return to a list frustrating and can fight the router's own restoration behavior.

Route focus

After meaningful navigation, keyboard and screen-reader users need a clear indication of the new context. A common strategy is to:

  • update the document title;
  • move focus to the main heading or main region when appropriate;
  • avoid stealing focus during minor search-param updates.

Changing:

text
?page=2

may not require the same focus behavior as changing from:

text
/settings
→ /tasks

The first may be an update within an existing context; the second is a meaningful page transition.

Canonical URLs

If all of these values mean page 1:

text
?page=0
?page=-4
?page=abc

consider redirecting to a canonical URL:

text
/tasks?page=1

instead of carrying invalid state indefinitely. Canonicalization improves:

  • sharing;
  • analytics;
  • caching;
  • debugging.

Whether to redirect or silently normalize is a product and routing decision, but it should be consistent.

Route-level metadata

A route should own its title and other metadata where the framework supports route metadata. Avoid one global Effect that manually inspects location strings and sets document.title through a giant switch. Router and framework metadata APIs give that responsibility a clearer owner, especially when rendering on the server.

A failed lazy chunk or loader should not leave the user in a blank screen. Keep the application shell and navigation available when possible, and offer a meaningful reload or retry path.

This connects routing directly to Suspense and Error Boundary design. Loading and failure behavior are part of the route experience, not separate concerns to add only after the happy path works.

Reader page: /react/lesson/106/react-router-route-structure-url-state-and-navigation