In the previous lesson it became clear what client-side routing is, why React doesn't ship with it, and which of the three ways of using React Router v7 this course uses: data mode, with createBrowserRouter and RouterProvider. Now it's time to write it. In this lesson you'll turn CicloUrbano from a single-screen application into a routed one: you'll install the package, create the route map in src/routes.jsx, mount the router provider in main.jsx without breaking the existing provider order, reorganise the project with a src/pages/ folder, replace the Header's <a href="#…"> links with real ones — finally settling module 2's debt — read the detail page's identifier with useParams, and move the TypeSelector filter from useState into the URL itself with useSearchParams. By the end, every CicloUrbano screen will have its own address.

Contents

  1. Starting point and reorganisation plan
  2. Installing the package
  3. The src/pages/ folder and what stays in src/components/
  4. The route map: src/routes.jsx
  5. Mounting the router in main.jsx
  6. Why the providers go outside the router
  7. Links: <Link> vs. <a href>
  8. <NavLink> and the active class: the Header's nav
  9. Dynamic segments and useParams
  10. What to do when the identifier doesn't exist
  11. Query parameters with useSearchParams
  12. The wildcard route and the not-found page
  13. How React Router picks the best match

  1. Starting point and reorganisation plan

This is how App.jsx looks today, at the end of module 5: a single screen that shows the catalogue with its search box, its filter and its booking form, all wrapped in Layout.

// src/App.jsx — the starting point (abridged)
import Layout from './components/Layout.jsx';
import BikeSearch from './components/BikeSearch.jsx';
import TypeSelector from './components/TypeSelector.jsx';
import BikeList from './components/BikeList.jsx';
import BookingPanel from './components/BookingPanel.jsx';
import { useState } from 'react';
import { bikes, stations } from './data/domain.js';

function App() {
  const [searchTerm, setSearchTerm] = useState('');
  const [chosenType, setChosenType] = useState('todos');
  const [selectedId, setSelectedId] = useState(null);

  const visibleBikes = bikes
    .filter((bike) => chosenType === 'todos' || bike.type === chosenType)
    .filter((bike) => bike.model.toLowerCase().includes(searchTerm.toLowerCase()));

  return (
    <Layout>
      <BikeSearch searchTerm={searchTerm} onSearchTermChange={setSearchTerm} />
      <TypeSelector chosenType={chosenType} onTypeChange={setChosenType} />
      <BikeList
        bikes={visibleBikes}
        stations={stations}
        selectedId={selectedId}
        onSelect={setSelectedId}
      />
      <BookingPanel />
    </Layout>
  );
}

export default App;

The plan for this lesson, in order:

flowchart LR
    A["1. npm install<br/>react-router"] --> B["2. Create src/pages/<br/>with one screen per route"]
    B --> C["3. src/routes.jsx<br/>createBrowserRouter"]
    C --> D["4. main.jsx<br/>RouterProvider"]
    D --> E["5. Header<br/>NavLink"]
    E --> F["6. useParams<br/>useSearchParams"]

By the end, App.jsx disappears as a screen: its content moves into CataloguePage, and the router takes over the root's role. It's the biggest restructuring of the course so far, so it's worth doing it step by step and checking that the application still starts at each one.

  1. Installing the package

npm install react-router

That's it. No Vite plugins, no extra configuration: in data mode, React Router is a regular dependency.

About the package name, because it's the first point of confusion you'll run into:

Package Version What it is
react-router-dom v6 and earlier The package installed in web applications. Exported BrowserRouter, Link, etc.
react-router v6 and earlier The platform-independent core. Not installed directly
react-router v7 The only package you need. Everything comes from here
react-router-dom v7 Still published, but only re-exports react-router. Exists so migrated projects don't break

Practical consequence: every example you find online with from 'react-router-dom' works the same way once you change the import to from 'react-router'. Pick one and be consistent; having both installed is a source of context errors that are very hard to diagnose, because you can end up with two copies of the router in memory.

Check the installed version:

npm list react-router
# [email protected]
# └── [email protected]

  1. The src/pages/ folder and what stays in src/components/

Before writing the map you need to decide where everything lives. We're introducing a new folder:

src/
├── components/     ← reusable interface pieces
├── contexts/
├── data/
├── hooks/
├── pages/           ← NEW: one screen per route
├── reducers/
├── utils/
├── main.jsx
└── routes.jsx        ← NEW: the route map

The rule that separates the two folders:

src/pages/ src/components/
What it is The full screen tied to a route A reusable interface piece
Who renders it The router, and only the router Other components or pages
How many times it appears Once, at its route As many times as needed
Can it read useParams Yes, it's its natural place Preferably not: receive it via props
Examples CataloguePage, SignInPage BikeCard, Notice, Modal

That last point is more than an ordering convention: a page can depend on the router; a reusable component, the less the better. If BikeCard calls useParams() internally, it can no longer be used on a screen that doesn't have that parameter, and testing it in module 9 will force you to wrap it in a fake router. The page reads the parameter and passes it down via props: the component remains a function of its props, as in 02-03.

These are CicloUrbano's pages. We'll start with minimal versions and fill them in as we go:

// src/pages/CataloguePage.jsx
import { useState } from 'react';
import BikeSearch from '../components/BikeSearch.jsx';
import TypeSelector from '../components/TypeSelector.jsx';
import BikeList from '../components/BikeList.jsx';
import { bikes, stations } from '../data/domain.js';

function CataloguePage() {
  const [searchTerm, setSearchTerm] = useState('');
  const [chosenType, setChosenType] = useState('todos');

  const visibleBikes = bikes
    .filter((bike) => chosenType === 'todos' || bike.type === chosenType)
    .filter((bike) => bike.model.toLowerCase().includes(searchTerm.toLowerCase()));

  return (
    <section>
      <h2>Bike catalogue</h2>
      <BikeSearch searchTerm={searchTerm} onSearchTermChange={setSearchTerm} />
      <TypeSelector chosenType={chosenType} onTypeChange={setChosenType} />
      <BikeList bikes={visibleBikes} stations={stations} />
    </section>
  );
}

export default CataloguePage;
// src/pages/StationsPage.jsx
import StationCard from '../components/StationCard.jsx';
import { stations, bikes } from '../data/domain.js';

function StationsPage() {
  return (
    <section>
      <h2>Stations</h2>
      <ul>
        {stations.map((station) => (
          <li key={station.id}>
            <StationCard
              station={station}
              bikesAtStation={bikes.filter((b) => b.stationId === station.id)}
            />
          </li>
        ))}
      </ul>
    </section>
  );
}

export default StationsPage;
// src/pages/BookingsPage.jsx
import { useBookings } from '../contexts/BookingsContext.jsx';
import BookingsPanel from '../components/BookingsPanel.jsx';

function BookingsPage() {
  const { state } = useBookings();

  return (
    <section>
      <h2>My bookings</h2>
      <BookingsPanel bookings={state.bookings} />
    </section>
  );
}

export default BookingsPage;

You'll build out SignInPage in 06-05 (it needs signIn and the return to the original destination), so a skeleton is enough for now. And there's one more, the most important for this lesson:

// src/pages/NotFoundPage.jsx
import { Link } from 'react-router';

function NotFoundPage() {
  return (
    <section>
      <h2>This page doesn't exist</h2>
      <p>
        The address you typed doesn't correspond to any CicloUrbano screen.
        The link may be outdated, or there may be a typo.
      </p>
      <Link to="/">Back to the catalogue</Link>
    </section>
  );
}

export default NotFoundPage;

  1. The route map: src/routes.jsx

Here's the heart of data mode: the map is an array of JavaScript objects, not JSX markup.

// src/routes.jsx
import { createBrowserRouter } from 'react-router';

import CataloguePage from './pages/CataloguePage.jsx';
import BikeDetailPage from './pages/BikeDetailPage.jsx';
import StationsPage from './pages/StationsPage.jsx';
import StationDetailPage from './pages/StationDetailPage.jsx';
import BookingsPage from './pages/BookingsPage.jsx';
import NewBookingPage from './pages/NewBookingPage.jsx';
import SignInPage from './pages/SignInPage.jsx';
import WorkshopPage from './pages/WorkshopPage.jsx';
import NotFoundPage from './pages/NotFoundPage.jsx';

export const router = createBrowserRouter([
  { path: '/', element: <CataloguePage /> },
  { path: '/bicicletas/:bicicletaId', element: <BikeDetailPage /> },
  { path: '/estaciones', element: <StationsPage /> },
  { path: '/estaciones/:estacionId', element: <StationDetailPage /> },
  { path: '/reservas', element: <BookingsPage /> },
  { path: '/reservas/nueva', element: <NewBookingPage /> },
  { path: '/acceso', element: <SignInPage /> },
  { path: '/taller', element: <WorkshopPage /> },
  { path: '*', element: <NotFoundPage /> }
]);

Let's unpack the decisions:

  • The file is .jsx, not .js. It contains JSX in the element property, so the extension has to allow it or Vite will complain.
  • path is the URL pattern, with a leading / on top-level routes. Segments starting with : are dynamic.
  • element receives an already-created React element (<CataloguePage />), not the component (CataloguePage). It's a subtle but strict difference: passing the un-instantiated component is a classic mistake and produces a blank screen.
  • router is exported by name, following the project's convention: export default for components, named exports for everything else.
  • createBrowserRouter is called exactly once, outside any component. If you called it inside a component, every render would create a new router and you'd lose all navigation state.
  • The * route goes last for readability, not out of necessity: order no longer decides anything (section 13).

There's no nesting yet. Layout isn't in the map yet, so screens are painted on their own, without a header or footer. It's a deliberate intermediate state: you'll fix it in 06-03, which is exactly the nested-routes lesson.

  1. Mounting the router in main.jsx

Remember how the entry point looked at the end of module 5:

// src/main.jsx — BEFORE
import { StrictMode } from 'react';
import { createRoot } from 'react-dom/client';
import App from './App.jsx';
import ErrorBoundary from './components/ErrorBoundary.jsx';
import { ThemeProvider } from './contexts/ThemeContext.jsx';
import { UserProvider } from './contexts/UserContext.jsx';
import { reportError } from './utils/monitoring.js';
import './index.css';

createRoot(document.getElementById('root')).render(
  <StrictMode>
    <ErrorBoundary title="CicloUrbano isn't available right now" onLog={reportError}>
      <ThemeProvider>
        <UserProvider>
          <App />
        </UserProvider>
      </ThemeProvider>
    </ErrorBoundary>
  </StrictMode>
);

The change consists of replacing <App /> with <RouterProvider router={router} />:

// src/main.jsx — AFTER
import { StrictMode } from 'react';
import { createRoot } from 'react-dom/client';
import { RouterProvider } from 'react-router';
import { router } from './routes.jsx';
import ErrorBoundary from './components/ErrorBoundary.jsx';
import { ThemeProvider } from './contexts/ThemeContext.jsx';
import { UserProvider } from './contexts/UserContext.jsx';
import { reportError } from './utils/monitoring.js';
import './index.css';

createRoot(document.getElementById('root')).render(
  <StrictMode>
    <ErrorBoundary title="CicloUrbano isn't available right now" onLog={reportError}>
      <ThemeProvider>
        <UserProvider>
          <RouterProvider router={router} />
        </UserProvider>
      </ThemeProvider>
    </ErrorBoundary>
  </StrictMode>
);

Points worth a comment:

  • RouterProvider doesn't accept children. Everything painted below comes out of the route map. If you write <RouterProvider router={router}><Something /></RouterProvider>, that <Something /> will never show up.
  • App.jsx is no longer imported. You can delete it or leave it empty; its role is now filled by routes.jsx (what gets painted) and Layout (how it's framed, from 06-03 on).
  • StrictMode stays at the root, as it has since 01-03. It will keep causing the double mount in development, and that includes the pages' effects: nothing new compared with 05-02.
  • BookingsProvider and NoticesProvider don't appear here. In 06-03 you'll place them inside Layout, the root route, alongside the rest of the application's frame. You could also lift them up into main.jsx; the decision is justified in the next lesson.

With this you can already run npm run dev, type http://localhost:5173/estaciones into the address bar, and see the station list. It's the first moment in the course where the URL means something.

  1. Why the providers go outside the router

The order StrictMode > ErrorBoundary > ThemeProvider > UserProvider > RouterProvider isn't arbitrary. Each layer wraps the next for a specific reason:

flowchart TD
    SM["StrictMode<br/><i>development checks</i>"] --> LE["ErrorBoundary<br/><i>catches failures from EVERYTHING,<br/>including the router</i>"]
    LE --> PT["ThemeProvider<br/><i>survives route changes</i>"]
    PT --> PU["UserProvider<br/><i>the session is NOT lost when navigating</i>"]
    PU --> RP["RouterProvider<br/><i>decides which screen to paint</i>"]
    RP --> PAG["The active page<br/><i>changes with the URL</i>"]
    style RP fill:#e0f2fe
    style PAG fill:#dcfce7

The key argument: whatever is above the router doesn't get unmounted when navigating. If UserProvider were inside a route, moving from / to /estaciones would unmount the provider, its useState would reset, and the user would lose their session on every menu click. The same goes for the theme: you'd flip from dark to light every time you changed screens.

As a table, the criterion for deciding where each provider goes:

Situation Where it goes
The state must survive the whole life of the application (session, theme) Outside the router, in main.jsx
The state belongs to the visual frame and several screens consume it (notices, bookings) On the root route (Layout), 06-03
The state only matters to one screen (the catalogue's search term) Inside that page

And the global ErrorBoundary above everything has an extra reason: if the router itself throws an error — a misconfigured route, an element that isn't an element — someone has to catch it. In 06-03 you'll see there's also errorElement, which works inside the router, per route branch; the two mechanisms coexist and split the work.

  1. Links: <Link> vs. <a href>

Time to settle module 2's debt. Let's recall how Header has read ever since:

// src/components/Header.jsx — the version with the debt
<nav>
  <a href="#catalogo">Catalogue</a>
  <a href="#estaciones">Stations</a>
  <a href="#reservas">My bookings</a>
</nav>

The temptation is to replace #catalogo with /:

<a href="/">Catalogue</a>   {/* ❌ Don't do this in an SPA */}

And it's a mistake, even though it "works." Compare what happens with each one:

<a href="/estaciones"> <Link to="/estaciones">
What HTML does it produce? <a href="/estaciones"> <a href="/estaciones"> (the same!)
On click The browser tears down the application and requests the page from the server Intercepts the click, pushStates, and repaints
Full reload Yes No
React state Everything is lost: session, theme, bookings, notices Intact
Time Hundreds of milliseconds, with a blank screen Instant
Ctrl-click / middle-click Opens in a new tab ✅ Opens in a new tab ✅
Search engines and screen readers See it as a link ✅ See it as a link ✅

That detail in the last two rows matters, and it usually surprises people: <Link> renders a real <a> with its href. It isn't a <div> with an onClick, a common mistake when someone builds their own links from scratch. That's why it keeps everything good about an HTML link — opening in a new tab, copying the address, indexing, keyboard navigation, being announced correctly by a screen reader, as 03-06 required — and only changes the behaviour of a plain click, which it intercepts with preventDefault().

The rule is simple:

  • Navigation inside the application → <Link to="…">.
  • A link to another website, a PDF, or mailto: → a plain old <a href="…">. React Router shouldn't intercept it.
// Correct: internal with Link, external with a
<Link to="/estaciones">Stations</Link>
<a href="https://www.ciclourbano.test/ayuda" target="_blank" rel="noreferrer">
  Help centre
</a>

A tip that saves hours of debugging: if you ever notice that clicking a link loses the session or makes the whole screen flicker, it's almost certainly an internal <a href> you forgot to convert. It's the most repeated mistake when routing an existing application.

  1. <NavLink> and the active class: the Header's nav

A navigation menu needs something more: visually marking which screen is current. <NavLink> is a <Link> that knows whether its destination matches the active URL.

// src/components/Header.jsx — module 2's debt, settled
import { NavLink } from 'react-router';
import UserMenu from './UserMenu.jsx';
import styles from './Header.module.css';

function Header() {
  // Function that decides the class based on the link's state
  const linkClasses = ({ isActive }) =>
    isActive ? `${styles.link} ${styles.active}` : styles.link;

  return (
    <header className={styles.header}>
      <NavLink to="/" className={styles.brand}>
        CicloUrbano
      </NavLink>

      <nav aria-label="Main navigation">
        <NavLink to="/" end className={linkClasses}>
          Catalogue
        </NavLink>
        <NavLink to="/estaciones" className={linkClasses}>
          Stations
        </NavLink>
        <NavLink to="/reservas" className={linkClasses}>
          My bookings
        </NavLink>
      </nav>

      <UserMenu />
    </header>
  );
}

export default Header;

Three things worth explaining:

className can be a function. NavLink calls it with an object { isActive, isPending, isTransitioning } and uses whatever it returns. It's the only prop in the whole project that behaves like this, and it's worth remembering why: the component doesn't know what class names your CSS Module uses, so it lets you decide. The same goes for style and for children:

{/* children as a function: useful for adding an icon only to the active one */}
<NavLink to="/reservas">
  {({ isActive }) => (
    <>
      {isActive && <span aria-hidden="true">▸ </span>}
      My bookings
    </>
  )}
</NavLink>

The end prop isn't optional on the catalogue link. By default, a NavLink is considered active if the URL starts with its destination. Since every route starts with /, the "Catalogue" link would always show as marked, even while on /estaciones. end requires an exact match and solves the problem:

Current URL <NavLink to="/"> <NavLink to="/" end> <NavLink to="/estaciones">
/ active active inactive
/estaciones active ❌ inactive ✅ active
/estaciones/est-02 active ❌ inactive ✅ active (desirable)

Notice the last row: for the "Stations" link we do want the default behaviour, because while viewing a station's detail page it makes sense for the section to stay marked. That's why end is only on the first one.

Accessibility. Marking the active link with colour alone isn't enough (03-06). NavLink automatically adds aria-current="page" to the active element, which a screen reader announces as "current page." Take advantage of that attribute in the CSS too, so you don't depend on a class:

/* src/components/Header.module.css */
.link {
  color: var(--color-text);
  text-decoration: none;
  padding: calc(var(--space) / 2) var(--space);
  border-radius: var(--radius);
}

.link:hover {
  background: var(--color-border);
}

.active,
.link[aria-current='page'] {
  color: var(--color-brand);
  font-weight: 700;
  /* Besides the colour, a non-chromatic indicator */
  box-shadow: inset 0 -2px 0 var(--color-brand);
}

  1. Dynamic segments and useParams

The route /bicicletas/:bicicletaId has a dynamic segment: the colon signals that this piece is variable and its value has to be captured.

URL Does it match /bicicletas/:bicicletaId? useParams() returns
/bicicletas/bici-003 Yes { bicicletaId: 'bici-003' }
/bicicletas/42 Yes { bicicletaId: '42' }
/bicicletas No (segment missing) —
/bicicletas/bici-003/piezas No (extra segment) —

This is how it's read on the page:

// src/pages/BikeDetailPage.jsx
import { useParams, Link } from 'react-router';
import { bikes, stations } from '../data/domain.js';
import StatusBadge from '../components/StatusBadge.jsx';
import NotFoundPage from './NotFoundPage.jsx';

function BikeDetailPage() {
  const { bicicletaId } = useParams();

  const bike = bikes.find((b) => b.id === bicicletaId);

  // The parameter can be anything: the user is the one typing the URL
  if (!bike) {
    return <NotFoundPage resource="bike" id={bicicletaId} />;
  }

  const station = stations.find((s) => s.id === bike.stationId);

  return (
    <article>
      <h2>{bike.model}</h2>
      <StatusBadge status={bike.status} />
      <dl>
        <dt>Type</dt>
        <dd>{bike.type}</dd>
        <dt>Station</dt>
        <dd>
          <Link to={`/estaciones/${bike.stationId}`}>
            {station ? station.name : 'Unassigned'}
          </Link>
        </dd>
        <dt>Price per hour</dt>
        <dd>€{bike.pricePerHour.toFixed(2)}</dd>
      </dl>
      <Link to="/">Back to the catalogue</Link>
    </article>
  );
}

export default BikeDetailPage;

And the link that leads here, from BikeCard:

// src/components/BikeCard.jsx — excerpt
<h3>
  <Link to={`/bicicletas/${bike.id}`}>{bike.model}</Link>
</h3>

Type conversion is your responsibility. This is the point that causes the most silent failures:

Every route parameter is a string. Always. The URL is text; React Router has no way of knowing whether 42 is a number, a postal code, or an identifier.

It doesn't cause trouble in CicloUrbano, because the identifiers are already strings (bici-003). But if your API used numeric identifiers, this would fail in a baffling way:

const { bicicletaId } = useParams();     // '3', a STRING

bikes.find((b) => b.id === bicicletaId); // ❌ 3 === '3' is false → undefined
bikes.find((b) => b.id == bicicletaId);  // ⚠️ works, but == is a bad idea
bikes.find((b) => b.id === Number(bicicletaId)); // ✅ explicit conversion

Convert it as early as possible, in a single place, and validate the result:

const { bicicletaId } = useParams();
const numericId = Number(bicicletaId);

if (!Number.isInteger(numericId) || numericId <= 0) {
  return <NotFoundPage resource="bike" id={bicicletaId} />;
}

  1. What to do when the identifier doesn't exist

Anyone can type /bicicletas/bici-999 into the address bar, or arrive from an old link to a bike that's been decommissioned. You have three strategies, and choosing well matters:

Strategy How When it's the right call
Paint the "not found" in place if (!bike) return <NotFoundPage … /> Recommended. The URL is preserved, the user can fix it, and the link stays shareable for debugging
Redirect to the list <Navigate to="/" replace /> When the detail page makes no sense without context and the list is a useful alternative. Covered in 06-04
Throw and let the router catch it throw new Response('Not found', { status: 404 }) + errorElement Large applications with centralised error handling. Covered in 06-03

Expand NotFoundPage so it serves both uses — a nonexistent URL and a nonexistent resource — with a tailored message:

// src/pages/NotFoundPage.jsx
import { Link, useLocation } from 'react-router';
import Notice from '../components/Notice.jsx';

/**
 * CicloUrbano's "not found" screen.
 * Props:
 *  - resource (string, optional): 'bike', 'station'… If missing, it's an unknown URL
 *  - id       (string, optional): the id that wasn't found
 */
function NotFoundPage({ resource, id }) {
  const { pathname } = useLocation();

  const title = resource
    ? `No ${resource} exists with the id "${id}"`
    : "This page doesn't exist";

  return (
    <section>
      <Notice tone="error" title={title}>
        <p>
          {resource
            ? 'It may have been decommissioned, or the link may be outdated.'
            : `The address ${pathname} doesn't correspond to any CicloUrbano screen.`}
        </p>
      </Notice>
      <p>
        <Link to="/">Go to the catalogue</Link> · <Link to="/estaciones">View stations</Link>
      </p>
    </section>
  );
}

export default NotFoundPage;

An honest caveat worth knowing: this screen doesn't return an HTTP 404 status code. The server delivered index.html with a 200 status, and the 404 is purely visual. For a search engine to receive the correct code you need server-side rendering (10-01). In an internal application like CicloUrbano's panel that's not a problem; on a public website, it is.

  1. Query parameters with useSearchParams

Back to CataloguePage. The type filter currently lives in a useState, and that has the same flaw we called out in 06-01: if an operator filters by electrica and sends the address to a colleague, the colleague sees the catalogue unfiltered. The filter is information about what the user is looking at: it belongs in the URL.

useSearchParams is to query parameters what useState is to local state, with a deliberately similar signature:

const [searchParams, setSearchParams] = useSearchParams();

With the difference that searchParams is a standard browser URLSearchParams object, not a plain object. Its methods:

Method What it does
searchParams.get('tipo') Returns the value, or null if it's not there
searchParams.getAll('tipo') All the values, if the key repeats
searchParams.has('tipo') true / false
searchParams.set('tipo', 'carga') Sets the value (on a copy, see below)
searchParams.delete('tipo') Removes the key
searchParams.toString() "tipo=carga&orden=precio"

Here's how the catalogue ends up:

// src/pages/CataloguePage.jsx — with the filter in the URL
import { useState } from 'react';
import { useSearchParams } from 'react-router';
import BikeSearch from '../components/BikeSearch.jsx';
import TypeSelector from '../components/TypeSelector.jsx';
import BikeList from '../components/BikeList.jsx';
import { bikes, stations } from '../data/domain.js';

function CataloguePage() {
  const [searchTerm, setSearchTerm] = useState('');
  const [searchParams, setSearchParams] = useSearchParams();

  // The URL rules: if there's no ?tipo=, the filter is 'todos'
  const chosenType = searchParams.get('tipo') ?? 'todos';

  function handleTypeChange(newType) {
    // ALWAYS start from the current parameters so the rest aren't lost
    const next = new URLSearchParams(searchParams);

    if (newType === 'todos') {
      next.delete('tipo');   // no filter, clean URL: '/' and not '/?tipo=todos'
    } else {
      next.set('tipo', newType);
    }

    // replace: changing the filter shouldn't fill up the history stack
    setSearchParams(next, { replace: true });
  }

  const visibleBikes = bikes
    .filter((bike) => chosenType === 'todos' || bike.type === chosenType)
    .filter((bike) => bike.model.toLowerCase().includes(searchTerm.toLowerCase()));

  return (
    <section>
      <h2>Bike catalogue</h2>
      <BikeSearch searchTerm={searchTerm} onSearchTermChange={setSearchTerm} />
      <TypeSelector chosenType={chosenType} onTypeChange={handleTypeChange} />
      {visibleBikes.length === 0 ? (
        <NoResultsNotice searchTerm={searchTerm} type={chosenType} />
      ) : (
        <BikeList bikes={visibleBikes} stations={stations} />
      )}
    </section>
  );
}

export default CataloguePage;

The essentials of this code:

  • TypeSelector hasn't been touched. It's still the controlled component from 03-04, with its chosenType and onTypeChange props. The only thing that changed is where the value comes from and where the change goes. That's exactly what we were after by keeping components decoupled from the router (section 3).
  • It's copied before being modified: new URLSearchParams(searchParams). Mutating the object the hook returns doesn't repaint anything and can cause inconsistencies, for the same immutability reason as 05-01.
  • ?tipo=todos gets removed instead of written. A default value shouldn't clutter the URL; / and /?tipo=todos would show the same thing yet be two different addresses, bad for sharing and for analytics.
  • { replace: true } stops every click on the filter from adding a history entry. If a user tries five types, they won't want to press "back" five times to leave the catalogue. It's a design judgement call: for pagination, leaving a trail is usually preferred.
  • The search term stays in useState. A deliberate decision: it's typed letter by letter, and moving it into the URL would generate dozens of entries or require combining it with the useDebounce from 05-06. Once the search box needs to be shareable, the pattern will be useSearchParams + useDebounce.

What's gained exactly by turning React state into URL state:

Before (useState) After (useSearchParams)
/ always, no matter the filter /?tipo=electrica
Sharing the link loses the filter The colleague sees exactly the same thing
F5 returns to the unfiltered catalogue F5 keeps the filter
"Back" leaves the application "Back" returns to the previous filter (if you don't use replace)
Analytics can't tell filters apart You can measure which type gets looked up most
The initial state has to be invented The URL dictates it

And the honest trade-off: the URL is public and shareable, so only things that are fine to be seen and saved should go there. Filters, sort orders, page numbers, the active tab: yes. Personal data, form contents, or any secret: never.

  1. The wildcard route and the not-found page

The last entry in the map is:

{ path: '*', element: <NotFoundPage /> }

The asterisk is the wildcard route (splat route): it matches any URL that no other route has claimed. Its job is to turn a typo into a useful screen instead of a void. Without it, /estacionez would render absolutely nothing: a blank screen, with no error in the console. It's a baffling failure, and forgetting that line is very common.

The value captured by the wildcard is read with the key '*', useful if you want to show it or log it:

const params = useParams();
console.log(params['*']); // 'estacionez/algo/mas'

A wildcard is also useful at an intermediate point, not just at the root. { path: '/documentacion/*' } captures any depth under that prefix, a common pattern when integrating external content. CicloUrbano doesn't need it.

  1. How React Router picks the best match

A natural question: if the URL is /reservas/nueva, why doesn't it match /reservas or *? The answer is one of React Router's best design decisions since v6:

React Router scores every route and picks the most specific one, no matter what order you wrote them in the array.

The scoring criteria, simplified:

Segment type Score Example
Literal segment High estaciones
Dynamic segment Medium :estacionId
Wildcard * Lowest *

Applied to /estaciones/est-02, with the full map:

Candidate route Does it match? Score Result
/estaciones/:estacionId Yes literal + dynamic → high ✅ Wins
/estaciones No, extra segment — Discarded
* Yes lowest Loses

Compare that with React Router v5 and earlier, where <Switch> took the first route that matched in the order it was written. That forced you to sort the array from most to least specific, to use exact everywhere, and it produced errors that were very hard to spot: a route placed two lines too high up would "eat" all the others. Today you can write the map in whatever order reads best to you.

Two nuances worth keeping in mind:

  • The match has to be complete. /estaciones does not match the URL /estaciones/est-02 unless it has child routes that absorb the rest (06-03) or it ends in *.
  • end on NavLink is a different thing. It only governs the link's active class, not which route gets painted. It's normal to mix them up at first.
flowchart TD
    URL["URL: /estaciones/est-02"] --> CAND{"Candidate routes"}
    CAND --> R1["/estaciones/:estacionId<br/>literal + dynamic"]
    CAND --> R2["/estaciones<br/>❌ doesn't cover the whole URL"]
    CAND --> R3["*<br/>lowest score"]
    R1 --> GANA["✅ Renders<br/>StationDetailPage<br/>params: { estacionId: 'est-02' }"]
    style GANA fill:#dcfce7
    style R2 fill:#fecaca

Common Mistakes and Tips

Passing the component instead of the element. { path: '/', element: CataloguePage } instead of element: <CataloguePage />. It doesn't throw a clear error: it simply paints nothing. Remember element receives already-created JSX.

Leaving internal <a href> tags unconverted. The symptom is unmistakable: clicking a link makes the screen flicker, and the session, the theme and the bookings get lost. Search your project for href="/ and convert everything internal to <Link>.

Forgetting end on the home NavLink. The "Catalogue" link stays marked as active on every screen. The same happens with any NavLink whose destination is a prefix of other routes.

Calling createBrowserRouter inside a component. Every render would create a new router, and the application would jump back to the initial route as soon as anything repainted. The map is created once, at module scope.

Mutating the useSearchParams object. searchParams.set('tipo', 'carga') on the object the hook returns doesn't trigger any repaint. Always copy: new URLSearchParams(searchParams).

Adding an extra ? when building the URL. setSearchParams('?tipo=carga') produces ??tipo=carga. Pass a URLSearchParams or a plain object and let React Router add the separator.

Expecting a number from useParams. Everything is a string. Convert and validate, in a single place, as early as possible.

Forgetting the * route. Any typo produces a silent blank screen, with no console error. It's one of the first bugs a user reports.

Tip: name the parameters the same way in the route and in the code. path: '/bicicletas/:bicicletaId' and const { bicicletaId } = useParams(). If the route says :id and you destructure bicicletaId, you'll get undefined with no warning at all.

Tip: keep a single source of truth per piece of data. If the chosen type lives in the URL, don't duplicate it in a useState "to have it handy." Duplicating it guarantees the two will fall out of sync sooner or later, exactly as 05-01 warned about derived state.

Tip: test a reload on every new route. Navigating with links always works; reloading is what uncovers server configuration problems. With Vite in development you won't see the failure, but it's worth keeping in mind what 06-01 covered about rewriting to index.html.

Exercises

Exercise 1: the station detail route

Add the station detail screen to CicloUrbano, /estaciones/:estacionId. It should:

  1. Read the parameter and look up the station in stations.
  2. Show its name, district and docks.
  3. List the bikes whose stationId matches, each linking to its detail page.
  4. Show the "not found" screen if the identifier doesn't exist.
  5. Include a link back to /estaciones.

Also add the link from StationCard to get there.

Exercise 2: sorting the list from the URL

In StationsPage, add a dropdown that lets you sort stations by name or by docks, saving the choice in a sort query parameter. Requirements:

  • The URL should end up as /estaciones?sort=docks.
  • The default value (name) shouldn't appear in the URL.
  • Any other query parameters that might exist shouldn't be lost.
  • An invalid value typed by hand (?sort=made-up) shouldn't break the screen.

Exercise 3: spot the bugs

This route map and this header have five bugs. Find them and fix them.

// src/routes.jsx
import { createBrowserRouter } from 'react-router';
import CataloguePage from './pages/CataloguePage.jsx';
import BikeDetailPage from './pages/BikeDetailPage.jsx';
import StationsPage from './pages/StationsPage.jsx';

function createRouter() {
  return createBrowserRouter([
    { path: '/', element: CataloguePage },
    { path: '/bicicletas/:id', element: <BikeDetailPage /> },
    { path: '/estaciones', element: <StationsPage /> }
  ]);
}

export default createRouter;
// src/components/Header.jsx
import { NavLink } from 'react-router';

function Header() {
  return (
    <nav>
      <NavLink to="/" className={({ isActive }) => (isActive ? 'active' : '')}>
        Catalogue
      </NavLink>
      <a href="/estaciones">Stations</a>
    </nav>
  );
}
// src/pages/BikeDetailPage.jsx — excerpt
const { bicicletaId } = useParams();
const bike = bikes.find((b) => b.id === bicicletaId);
return <h2>{bike.model}</h2>;

Solutions

Solution 1

// src/pages/StationDetailPage.jsx
import { useParams, Link } from 'react-router';
import { stations, bikes } from '../data/domain.js';
import StatusBadge from '../components/StatusBadge.jsx';
import NotFoundPage from './NotFoundPage.jsx';

function StationDetailPage() {
  const { estacionId } = useParams();
  const station = stations.find((s) => s.id === estacionId);

  if (!station) {
    return <NotFoundPage resource="station" id={estacionId} />;
  }

  const fleet = bikes.filter((bike) => bike.stationId === station.id);

  return (
    <article>
      <h2>{station.name}</h2>
      <p>
        {station.district} district · {station.docks} docks ·{' '}
        {fleet.length} bikes assigned
      </p>

      <h3>Station fleet</h3>
      {fleet.length === 0 ? (
        <p>No bikes are assigned to this station.</p>
      ) : (
        <ul>
          {fleet.map((bike) => (
            <li key={bike.id}>
              <Link to={`/bicicletas/${bike.id}`}>{bike.model}</Link>{' '}
              <StatusBadge status={bike.status} />
            </li>
          ))}
        </ul>
      )}

      <Link to="/estaciones">Back to stations</Link>
    </article>
  );
}

export default StationDetailPage;
// src/components/StationCard.jsx — excerpt with the link added
<h3>
  <Link to={`/estaciones/${station.id}`}>{station.name}</Link>
</h3>

With the route already present in routes.jsx, there's nothing else to register. And notice that /estaciones and /estaciones/est-02 coexist without a problem thanks to the scoring from section 13: the order in the array is irrelevant.

Solution 2

// src/pages/StationsPage.jsx
import { useSearchParams } from 'react-router';
import StationCard from '../components/StationCard.jsx';
import { stations, bikes } from '../data/domain.js';

const VALID_SORTS = ['name', 'docks'];
const DEFAULT_SORT = 'name';

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

  // Validation: a made-up value in the URL falls back to the default
  const rawSort = searchParams.get('sort');
  const sort = VALID_SORTS.includes(rawSort) ? rawSort : DEFAULT_SORT;

  function handleSortChange(event) {
    const newSort = event.target.value;
    const next = new URLSearchParams(searchParams); // copy: keeps everything else

    if (newSort === DEFAULT_SORT) {
      next.delete('sort');   // the default value doesn't clutter the URL
    } else {
      next.set('sort', newSort);
    }

    setSearchParams(next, { replace: true });
  }

  // Copy before sorting: sort() mutates the original array (05-01)
  const sortedStations = [...stations].sort((a, b) =>
    sort === 'docks' ? b.docks - a.docks : a.name.localeCompare(b.name)
  );

  return (
    <section>
      <h2>Stations</h2>

      <p>
        <label htmlFor="sort-stations">Sort by</label>{' '}
        <select id="sort-stations" value={sort} onChange={handleSortChange}>
          <option value="name">Name (A-Z)</option>
          <option value="docks">Docks (highest first)</option>
        </select>
      </p>

      <ul>
        {sortedStations.map((station) => (
          <li key={station.id}>
            <StationCard
              station={station}
              bikesAtStation={bikes.filter((b) => b.stationId === station.id)}
            />
          </li>
        ))}
      </ul>
    </section>
  );
}

export default StationsPage;

The four requirements are covered by: the VALID_SORTS allowlist (validation), the delete when the value is the default one (clean URL), the new URLSearchParams(searchParams) copy (not losing other parameters), and the [...stations] before sort (not mutating the original data). The <select> is a controlled component from 03-04, with the particularity that its value comes from the URL rather than a useState.

Solution 3

The five bugs:

  1. element: CataloguePage passes the component, not the element. It should be element: <CataloguePage />. Symptom: a blank screen with no clear error.
  2. createBrowserRouter inside a function exported as createRouter. If main.jsx calls it on every render, a new router gets created each time. It must be created once, at module scope, and exported by name: export const router = createBrowserRouter([...]);.
  3. The wildcard route { path: '*', element: <NotFoundPage /> } is missing. Any unknown URL leaves the screen silently blank.
  4. Name mismatch: the route declares :id, but the page destructures bicicletaId, which will be undefined. They have to match; the correct choice, per the module's map, is :bicicletaId in both places.
  5. In Header, <a href="/estaciones"> causes a full reload and loses all React state. It should be <NavLink to="/estaciones">.

And a sixth bug, latent, worth pointing out: in BikeDetailPage there's no check for whether bike exists, so /bicicletas/bici-999 will blow up with "Cannot read properties of undefined (reading 'model')." Add the guard from section 10. Also note that this error would indeed be caught by the global ErrorBoundary from 04-05, but it would bring down the entire application for something that's simply a mistyped URL: handling it in place is a much better experience.

Conclusion

CicloUrbano is now routed. You've installed react-router — the v7 package, knowing that react-router-dom is what you'll see in v6 projects — you've created src/routes.jsx with the full map as an array of objects, and you've mounted <RouterProvider router={router} /> in main.jsx, keeping the order StrictMode > ErrorBoundary > ThemeProvider > UserProvider, because whatever is above the router doesn't get unmounted when navigating, which is why the session and the theme survive screen changes. The project has a src/pages/ folder with one screen per route and a clear rule: pages can depend on the router; reusable components receive their data via props and remain independently testable.

Along the way you settled the debt Header had been carrying since module 2: the three <a href="#…"> tags are now <NavLink> elements with an active class, aria-current="page" for free, and the end prop on the catalogue link so it doesn't show as marked on every screen. You know that <Link> produces a real <a> — with everything good about an HTML link — and only intercepts the plain click, whereas an internal <a href> destroys the application and takes the session, theme and bookings down with it. You've read the detail page's identifier with useParams, remembering that every route parameter is a string and that converting and validating it is your job, and you've decided what to do when the resource doesn't exist. And you've turned the TypeSelector filter from React state into URL state with useSearchParams, gaining shareable links, reloads that keep the filter, and useful analytics, without touching a single line of the controlled component. Rounding out the picture are the wildcard route * — whose absence produces silent blank screens — and the scoring system that picks the most specific match without depending on the array's order.

One visible problem remains: screens are painted on their own, with no header or footer, because Layout was left out of the map. Reintroducing it as a component that wraps each page would work, but it would remount the header on every navigation and wouldn't let frame and screen share data. The correct fix is to make Layout the parent route of everything else and open a slot in it where the active child gets painted; you'll use the same idea to build the flota and incidencias tabs inside the station detail page, the breadcrumbs, and per-branch error handling. The next lesson is Nested Routes.

React Course

Module 1: Getting Started with React

Module 2: React Components

Module 3: Working with Events

Module 4: Advanced Component Concepts

Module 5: React Hooks

Module 6: Routing in React

Module 7: State Management

Module 8: Performance Optimization

Module 9: Testing React Applications

Module 10: Advanced Topics

Module 11: Project: Building a Complete Application

© Copyright 2026. All rights reserved