30% offEnding soon

Router in React: A Practical React Router Guide

20 min read

A router in React matches the current URL to the correct component tree, manages client-side navigation, and keeps the interface synchronized with browser history.

What a Router Does in React

React renders components, but React alone does not decide which component belongs at /products, /products/42, or another URL. A routing library supplies that relationship.

Client-side routing means the browser keeps the current document loaded while the application changes the URL and rendered components. A navigation normally follows this sequence:

  1. A Link receives a click.
  2. The router adds a location to the browser history stack.
  3. The router compares the new URL with the route definitions.
  4. React renders the matching route and any matching parent layouts.
  5. The browser's Back or Forward control moves to another history entry, and the router updates the rendered route again.

BrowserRouter uses the browser History API for this behavior. The History API can add a same-origin entry with pushState. Moving between existing entries with Back or Forward fires a popstate event.

That distinction matters in interviews. A router does more than swap components after a click. It keeps three pieces of state aligned: the visible URL, the history stack, and the rendered component tree.

One location, three viewsVisible URLReacttreeRouterHistorystack
The router keeps the URL, browser history, and React tree synchronized.

A normal anchor can request another document from a server. A React Router Link handles navigation inside the loaded application. Links remain the right semantic control when the user is moving to another location. Buttons are better for actions such as saving a form and then navigating after the save succeeds.

For another implementation exercise focused on browser history, see the History Router question.

Choose a React Router Mode and Set It Up

React Router has three primary modes. Each later mode adds routing assistance while giving the router more responsibility for application structure.

ModeRoute setupAdded capabilitiesA sensible use
DeclarativeBrowserRouter, Routes, and Route inside React renderingURL matching, navigation, and active-link stateA focused interview exercise or an application that wants direct control over data fetching
DataRoute configuration outside React renderingLoaders, actions, pending states, and fetchersAn application that wants routing and data work coordinated
FrameworkRoute modules and framework conventionsMore assistance with application structureA project built around the framework workflow

The exercise below stays in Declarative Mode. Mixing its component route tree with Data or Framework Mode APIs would obscure the route-matching concepts being tested.

Install the current package without copying a version number from an older tutorial:

npm install react-router

Current Declarative Mode documentation imports browser APIs from react-router. Older projects and tutorials commonly use react-router-dom, so imports should be checked against the project's installed package and starter before code is copied.

Place BrowserRouter around the application entry point:

// src/main.jsx
import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import { BrowserRouter } from "react-router";
import { App } from "./App.jsx";

createRoot(document.getElementById("root")).render(
  <StrictMode>
    <BrowserRouter>
      <App />
    </BrowserRouter>
  </StrictMode>,
);

BrowserRouter belongs at the boundary because every Link, Route, and routing hook below needs router context. The App component will own one canonical route tree, while tests will render that same component inside MemoryRouter.

Router context boundaryBrowserRouterAppLinkRoutesHooksAll consumers stay inside
BrowserRouter provides context to the routing features inside the application.

Data Mode may be preferable when route loaders and pending states are part of the exercise. A separate Mini React Query Core exercise is useful when the interview instead asks for a small data cache independent of route configuration.

Build a Nested Route Tree

The application is a small product catalog. It needs a home page, a shared products layout, a product index, a static creation page, a dynamic detail page, a legacy redirect, and not-found UI.

This is the complete App.jsx implementation used by the rest of the article and its tests:

// src/App.jsx
import {
  Link,
  NavLink,
  Navigate,
  Outlet,
  Route,
  Routes,
  useNavigate,
  useParams,
  useSearchParams,
} from "react-router";

const products = [
  { id: "1", name: "Mechanical keyboard" },
  { id: "2", name: "USB microphone" },
];

function SiteLayout() {
  return (
    <>
      <header>
        <Link to="/">Interview Shop</Link>
        <nav aria-label="Primary navigation">
          <NavLink
            to="/"
            end
            className={({ isActive }) => (isActive ? "active" : undefined)}
          >
            Home
          </NavLink>
          <NavLink
            to="/products"
            className={({ isActive }) => (isActive ? "active" : undefined)}
          >
            Products
          </NavLink>
        </nav>
      </header>

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

function HomePage() {
  return (
    <>
      <h1>Frontend interview shop</h1>
      <p>Practice route matching with a small product catalog.</p>
    </>
  );
}

function ProductsLayout() {
  return (
    <>
      <h1>Products</h1>
      <nav aria-label="Product navigation">
        <Link to="/products">Product list</Link>
        <Link to="/products/new">New product</Link>
      </nav>
      <Outlet />
    </>
  );
}

function ProductList() {
  const [searchParams, setSearchParams] = useSearchParams();
  const query = searchParams.get("query") ?? "";

  const visibleProducts = products.filter((product) =>
    product.name.toLowerCase().includes(query.toLowerCase()),
  );

  function updateQuery(event) {
    const nextQuery = event.target.value;
    setSearchParams(nextQuery ? { query: nextQuery } : {}, { replace: true });
  }

  return (
    <section aria-labelledby="product-list-heading">
      <h2 id="product-list-heading">Product list</h2>

      <label>
        Filter products
        <input value={query} onChange={updateQuery} />
      </label>

      {visibleProducts.length > 0 ? (
        <ul>
          {visibleProducts.map((product) => (
            <li key={product.id}>
              <Link to={product.id}>{product.name}</Link>
            </li>
          ))}
        </ul>
      ) : (
        <p>No products match this filter.</p>
      )}
    </section>
  );
}

function NewProductPage() {
  return (
    <section>
      <h2>New product</h2>
      <p>React Router ranks the matching routes and selects this static route over the less-specific dynamic route.</p>
      <Link to="/products">Cancel</Link>
    </section>
  );
}

function ProductPage() {
  const { productId } = useParams();
  const product = products.find((item) => item.id === productId);

  if (!product) {
    return (
      <section>
        <h2>Product not found</h2>
        <p>No product has the ID {productId}.</p>
        <Link to="/products">Return to products</Link>
      </section>
    );
  }

  return (
    <section>
      <h2>{product.name}</h2>
      <p>Product ID: {product.id}</p>
    </section>
  );
}

function NotFoundPage() {
  return (
    <section>
      <h1>Page not found</h1>
      <p>The requested application route does not exist.</p>
      <Link to="/">Return home</Link>
    </section>
  );
}

export function App() {
  return (
    <Routes>
      <Route path="/" element={<SiteLayout />}>
        <Route index element={<HomePage />} />

        <Route path="products" element={<ProductsLayout />}>
          <Route index element={<ProductList />} />
          <Route path="new" element={<NewProductPage />} />
          <Route path=":productId" element={<ProductPage />} />
        </Route>

        <Route
          path="catalog"
          element={<Navigate to="/products" replace />}
        />
        <Route path="*" element={<NotFoundPage />} />
      </Route>
    </Routes>
  );
}

A child route uses a relative path. path="products" sits below /, and path=":productId" sits below products, so the resulting URL is /products/:productId. Repeating the complete parent path in every child is unnecessary and makes route trees harder to move.

Outlet marks where a matched child renders. At /products/1, SiteLayout renders the page shell, ProductsLayout renders the products heading and navigation, and ProductPage fills the inner outlet.

Rendered tree at /products/1SiteLayoutShared site headerProductsLayoutProductsnavigationProductPagefills Outlet
A nested URL renders the child page inside both matching parent layouts.

An index route has no additional path segment. ProductList therefore renders in ProductsLayout at /products.

React Router ranks matching routes by specificity. At /products/new, both new and :productId have a compatible shape, but the static new route is more specific. Its position in this route tree does not turn "new" into a product ID.

Which route owns “new”?/products/newliteralplaceholderpath="new"selectedpath=":productId"compatible, loses
A static segment wins over a dynamic parameter when both shapes match.

The navigation APIs each have a different job.

  • Link moves to an ordinary destination selected by the user.
  • NavLink renders a link and exposes whether its destination is active.
  • Navigate performs a render-driven redirect.
  • useNavigate returns a function for navigation after an event or completed action.
  • useParams reads dynamic path segments.
  • useSearchParams reads and changes query-string state.

The site title, product links, and recovery links use Link. These controls represent destinations and remain understandable as navigation.

The primary navigation uses NavLink. Its className callback receives isActive, which lets the application mark the current section. The end prop on the home link prevents / from appearing active for every descendant URL.

The /catalog route uses Navigate to replace a legacy location with /products. The replace prop replaces the current history entry instead of leaving the obsolete URL as the previous entry. This prevents Back from immediately returning to the redirect route.

The Cancel button on the product form uses useNavigate because cancellation is an action followed by navigation. A finished create request could use the same pattern after confirming success. Navigation should not hide an error from a failed request.

useParams reads path parameters. For /products/2, the productId value is "2". URL values begin as strings, so numeric assumptions require explicit validation or conversion.

useSearchParams handles the filter at /products?query=keyboard. A path parameter identifies which resource or route is being viewed. A search parameter represents optional URL state such as a filter, sort choice, or page selection.

Two kinds of URL state/products/2?query=keyboardIdentityWhich product?View optionHow filtered?
Path parameters identify the resource; search parameters adjust the view.

Keeping the filter in the URL has an observable benefit: the filtered location can be copied, revisited, and traversed through browser history. State that has no value outside the current component, such as whether a temporary tooltip is open, usually does not need a URL.

A redirect used for authentication follows the same UI principle as the /catalog redirect, but it does not secure server data. The API must perform its own authentication and authorization checks.

For broader preparation around these choices, the React interview questions playbook places routing beside the other React topics commonly discussed in live interviews.

Handle Routing Edge Cases

Unknown URLs and unknown resources are different failures. The application handles both.

Where did “not found” happen?Requested URLRoute patternmatches?noPage not foundglobal catch-allyesProduct exists?noProduct not found
Route matching and resource lookup produce different not-found screens.

/products/999 matches a valid route pattern, but no product has ID 999. ProductPage renders “Product not found” inside the products layout. /does-not-exist matches no application page, so the path="*" route renders the general “Page not found” screen.

The splat in * can match the remaining path, including additional slashes. It belongs after the known routes conceptually, although route ranking determines the best match rather than relying on a first-match list.

A direct visit introduces another boundary. Clicking a Link occurs after the React application has loaded, so the client router can match the destination. Entering /products/1 in the address bar or refreshing that page sends the URL to the hosting server first.

A deployed single-page application must direct application URLs to its index.html. If the server returns its own 404 for /products/1, React never loads and the in-app catch-all route cannot run.

Who sees the URL first?App already loadedLink clickReact RouterRefresh or address barDeep URLHost serverindex.htmlserver 404React neverloadsRouteUI
Link clicks and page refreshes reach React Router through different boundaries.

Back and Forward navigation should restore the route represented by the selected history entry. The router then matches that location and renders the corresponding branch. Application code should derive route state from the router rather than maintain a second, competing pathname variable. The integration tests should navigate through multiple entries with navigate(-1) and navigate(1) and assert the heading after each history traversal.

History is a movable cursorBackForward//products/products/1currentRendered treeProducts + ProductList
Browser history selects an existing location, which the router renders again.

Static and dynamic conflicts need a deliberate test. /products/new must render the creation page, while /products/1 must render the detail page. Ranked matching makes the static route more specific, but a test records the product requirement and catches accidental route-tree changes.

Rendering errors are separate from unmatched URLs. A catch-all route does not catch a component exception. The ErrorBoundary exercise covers the React mechanism for replacing a failed render with recovery UI.

Test the Router Like an Interviewer

MemoryRouter stores history entries in memory. Its initialEntries prop provides controlled starting locations, which makes it suitable for route tests without changing the browser address bar.

Different history, same AppProductionBrowserRouterbrowser historyTestMemoryRouterinitialEntriesSame Appsame routes
BrowserRouter and MemoryRouter wrap the same application for different environments.

The tests below exercise the same exported App used by BrowserRouter in production. They check visible headings, links, and navigation results rather than private route configuration.

// src/App.test.jsx
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { MemoryRouter } from "react-router";
import { App } from "./App.jsx";

function renderAt(path) {
  return render(
    <MemoryRouter initialEntries={[path]}>
      <App />
    </MemoryRouter>,
  );
}

test("renders the home index route", () => {
  renderAt("/");

  expect(
    screen.getByRole("heading", { name: "Frontend interview shop" }),
  ).toBeInTheDocument();
});

test("renders the products index inside both parent layouts", () => {
  renderAt("/products");

  expect(
    screen.getByRole("navigation", { name: "Primary navigation" }),
  ).toBeInTheDocument();
  expect(
    screen.getByRole("heading", { name: "Products", level: 1 }),
  ).toBeInTheDocument();
  expect(
    screen.getByRole("heading", { name: "Product list", level: 2 }),
  ).toBeInTheDocument();
});

test("reads a product ID from a direct dynamic URL", () => {
  renderAt("/products/2");

  expect(
    screen.getByRole("heading", { name: "USB microphone" }),
  ).toBeInTheDocument();
  expect(screen.getByText("Product ID: 2")).toBeInTheDocument();
});

test("prefers the static new route over the dynamic product route", () => {
  renderAt("/products/new");

  expect(
    screen.getByRole("heading", { name: "New product" }),
  ).toBeInTheDocument();
  expect(screen.queryByText("No product has the ID new.")).not.toBeInTheDocument();
});

test("navigates from the product list to a product", async () => {
  const user = userEvent.setup();
  renderAt("/products");

  await user.click(
    screen.getByRole("link", { name: "Mechanical keyboard" }),
  );

  expect(
    screen.getByRole("heading", { name: "Mechanical keyboard" }),
  ).toBeInTheDocument();
  expect(screen.getByText("Product ID: 1")).toBeInTheDocument();
});

test("reads a product filter from the query string", () => {
  renderAt("/products?query=microphone");

  expect(
    screen.getByRole("link", { name: "USB microphone" }),
  ).toBeInTheDocument();
  expect(
    screen.queryByRole("link", { name: "Mechanical keyboard" }),
  ).not.toBeInTheDocument();
});

test("renders product-specific not-found UI for an invalid ID", () => {
  renderAt("/products/999");

  expect(
    screen.getByRole("heading", { name: "Product not found" }),
  ).toBeInTheDocument();
  expect(
    screen.getByRole("heading", { name: "Products", level: 1 }),
  ).toBeInTheDocument();
});

test("renders the catch-all page for an unknown route", () => {
  renderAt("/missing/page");

  expect(
    screen.getByRole("heading", { name: "Page not found" }),
  ).toBeInTheDocument();
});

initialEntries makes /products/2 behave like a controlled direct location from the router's perspective. It does not test a production hosting rewrite because no web server participates in this test.

React Router also provides createRoutesStub for isolated components that require router context. Complete route modules are better covered with integration or end-to-end tests. This exercise uses MemoryRouter because the interview requirement is to verify the complete Declarative Mode route tree as one unit.

Install vitest, jsdom, @testing-library/react, @testing-library/user-event, and @testing-library/jest-dom. Configure Vitest with environment: "jsdom" and a setup file containing import "@testing-library/jest-dom/vitest"; then run the test file with vitest. Add integration tests for the /catalog replacement redirect, the Cancel link, editing and clearing the query-string filter, and Back and Forward traversal, including assertions about whether each navigation replaces or pushes a history entry. The annual UIReady Premium plan is useful when a prepared browser starter and live tests are preferable to assembling that environment during a timed session.

React Router Interview Checklist

A strong explanation connects the browser behavior to the React tree. It should state that BrowserRouter watches the current location, route definitions select matching UI, nested children render through Outlet, and browser history lets Back and Forward revisit locations.

The implementation should also survive these checks:

  • / renders the root index route.
  • /products renders the product index inside both shared layouts.
  • /products/new selects the static route instead of treating "new" as an ID.
  • /products/:productId reads the parameter and validates the resource.
  • A query string controls shareable filter state without replacing the path parameter.
  • Link navigation and direct route rendering reach the same component tree.
  • An unknown application URL renders catch-all UI.
  • A valid deep link receives index.html from the production host.
  • Tests assert observable content and navigation rather than router internals.

Common mistakes include placing an absolute child path where a relative path is intended, omitting Outlet, treating every missing product as a global 404, and using a button for ordinary link navigation. Another mistake is assuming the catch-all route fixes server 404 responses during refreshes.

Useful follow-up questions expose whether the design can grow:

  • When would loaders, actions, pending states, or fetchers justify moving from Declarative Mode to Data Mode?
  • Should an authentication failure render a message, navigate to a sign-in route, or preserve the attempted destination?
  • Which checks belong in route UI, and which authorization checks must remain on the server?
  • How should loading and mutation errors appear without confusing them with unmatched routes?
  • What hosting fallback sends valid application URLs to index.html?
  • Which route behaviors need MemoryRouter integration tests, and which need a deployed end-to-end test?

Frequently asked questions

What does a router do in React?
A router matches the current URL to React components and provides navigation without requesting a new document for each route. It also responds when the browser moves through its Back and Forward history.
Should a React app use BrowserRouter or MemoryRouter?
BrowserRouter uses browser history and is the usual choice for an application running in a browser. MemoryRouter keeps locations in memory, which makes controlled route tests straightforward.
What is the difference between useParams and useSearchParams?
useParams reads dynamic path segments such as the productId in /products/42. useSearchParams reads and updates query-string values such as the filter in /products?query=keyboard.
Why does a React route work through a link but fail after a refresh?
Client navigation reaches React before route matching, but a refresh sends the URL to the hosting server first. The host must return the application's index.html for valid application URLs so React Router can handle them.
Does a protected React route secure an API?
No. A protected route can redirect the interface when a user lacks session state, but the server must still authenticate and authorize every protected request.