Open a shop site and click a product. The address bar changes, the page changes, and nothing reloads. Visitors expect that smooth switch now, and it is what makes a single-page app feel finished rather than half built. Without a router, the usual workaround is a pile of state: a variable holding the current view, a switch statement rendering one component or another, and links that are really buttons wearing a disguise. It works for two pages, then starts to creak. The address bar never changes, so a visitor cannot bookmark a product, share its link, or use the back button properly. React Router closes exactly that gap, and the question is when it is worth adding.
What I would check before using it
My first check is how many views the app really has. One page with a couple of panels does not need a router, and adding one just adds a dependency to keep updated. Three or more distinct views is where a router starts earning its place. Count the pages before reaching for the library.
My second check is whether the URLs need to mean anything. A shop lives on shareable links: a customer sends a product page to a friend, a search engine indexes it, a visitor bookmarks it. If the address bar never changes, none of that works, and the app is quietly broken for everyone who treats a URL as an address. If nobody shares or bookmarks a view, a router buys little.
My third check is the learning curve, and React Router is gentle here. The example below rests on four ideas: Routes and Route to map paths to components, Link to move between them, and useParams to read a value out of the URL. Anyone who knows React can read that code on the first pass. The cost is a dependency and a little setup.
My fourth check is the layout. Most apps repeat the same header and footer on every page, and React Router lets one layout component wrap the changing content instead of copying it into every view.
My verdict, as judgement: use React Router when an app has several views, when those views deserve real URLs, and when a shared layout would cut repetition. Skip it for a single view or a page that nobody links to. The recommendation flips when the page count drops to one, or the URLs stop mattering, because then the router is a dependency with no job to do.
One small shop, three routes, run end to end
The example is a small shop with three routes: the home page at /, a product list at /products, and a product detail page at /products/:id, where :id is a URL parameter read with useParams. The products are a canvas tote bag at $18, a ceramic mug at $14.50, and a notebook set at $9.25. Everything runs in plain Node with MemoryRouter and ReactDOMServer renderToString, so no browser and no build step are needed. MemoryRouter stands in for the BrowserRouter a real app would use, and every Link and useParams call sits inside it, because a Link rendered outside a router throws a cryptic error about a null context instead of rendering anything.
Here is the whole app:
const React = require("react");
const { renderToString } = require("react-dom/server");
const { MemoryRouter, Routes, Route, Link, useParams } = require("react-router");
const products = [
{ id: "1", name: "Canvas tote bag", price: 18.0 },
{ id: "2", name: "Ceramic mug", price: 14.5 },
{ id: "3", name: "Notebook set", price: 9.25 },
];
function Home() {
return React.createElement("div", null,
React.createElement("h1", null, "The Corner Shop"),
React.createElement("p", null, "Three useful things, fairly priced."),
React.createElement(Link, { to: "/products" }, "Browse products"));
}
function ProductList() {
return React.createElement("div", null,
React.createElement("h1", null, "Products"),
React.createElement("ul", null,
products.map((p) =>
React.createElement("li", { key: p.id },
React.createElement(Link, { to: "/products/" + p.id },
p.name + " - $" + p.price.toFixed(2))))));
}
function ProductDetail() {
const { id } = useParams();
const product = products.find((p) => p.id === id);
if (!product) {
return React.createElement("div", null,
React.createElement("h1", null, "Product not found"),
React.createElement("p", null, "No product has id " + id + "."),
React.createElement(Link, { to: "/products" }, "Back to products"));
}
return React.createElement("div", null,
React.createElement("h1", null, product.name),
React.createElement("p", null, "Price: $" + product.price.toFixed(2)),
React.createElement(Link, { to: "/products" }, "Back to products"));
}
function NotFound() {
return React.createElement("div", null,
React.createElement("h1", null, "Page not found"),
React.createElement(Link, { to: "/" }, "Back home"));
}
function App() {
return React.createElement(Routes, null,
React.createElement(Route, { path: "/", element: React.createElement(Home) }),
React.createElement(Route, { path: "/products", element: React.createElement(ProductList) }),
React.createElement(Route, { path: "/products/:id", element: React.createElement(ProductDetail) }),
React.createElement(Route, { path: "*", element: React.createElement(NotFound) }));
}
for (const path of process.argv.slice(2)) {
console.log("=== " + path + " ===");
console.log(renderToString(
React.createElement(MemoryRouter, { initialEntries: [path] },
React.createElement(App))));
}
This is the real output from running it with Node, for the home page and the ceramic mug detail page:
=== / ===
<div><h1>The Corner Shop</h1><p>Three useful things, fairly priced.</p><a href="/products" data-discover="true">Browse products</a></div>
=== /products/2 ===
<div><h1>Ceramic mug</h1><p>Price: $14.50</p><a href="/products" data-discover="true">Back to products</a></div>
Two things are worth noticing. First, the URL parameter did real work: visiting /products/2 rendered the ceramic mug and its $14.50 price, pulled from the same products array as the list, so the list and the detail page can never disagree about a price. Second, the Link components rendered as ordinary anchor tags with the right href, which is why the back button and bookmarks keep working: the router is built on plain links, not on a trick.
The thing that went wrong came from the first version of this app, which had no catch-all route and no guard for a missing product. Visiting an unknown address rendered a blank page, with no error and no message, and visiting /products/999 crashed outright. Here is the real output from that broken run:
=== /nowhere ===
=== /products/999 ===
ERROR: Cannot read properties of undefined (reading 'name')
The blank page is the nastier of the two. The crash at least tells you something failed, but a blank page looks like the app loaded and chose to show nothing, so a visitor just leaves. The fix is two small additions: a route with path "*" that renders a "Page not found" message, and a guard in ProductDetail that checks whether the product exists before reading its name. After adding both, the same paths render properly, as the full run showed: /products/999 gives "Product not found" with a link back to the list, and /nowhere gives "Page not found" with a link home. Unknown URLs and unknown ids are normal traffic, not edge cases, so both guards belong in the app from the start.
Common mistakes and how to fix them
First, leaving out the catch-all route, as in the broken run above. The symptom is a blank page on any mistyped or outdated URL, with no error anywhere to point at. The fix is a Route with path "*" at the end of the Routes list, rendering a helpful not-found page with a link home.
Second, trusting the URL parameter without checking it. The symptom is a crash, "Cannot read properties of undefined (reading 'name')", when a visitor opens a product id that does not exist, because find returns undefined and the code reads a property off it. The fix is the guard shown above: look the product up, and if it is missing, render a not-found message instead of the detail view.
Third, using a plain anchor tag instead of Link for navigation inside the app. The symptom is a full page reload on every click, which throws away the app state and defeats the whole point of a router. The fix is to use Link for every in-app move, and save plain anchor tags for links that leave the site.
My call on React Router
React Router turns the address bar into a real part of the app instead of a decoration. The shop above needed four ideas, three routes, and two small guards, and every URL now leads somewhere sensible, including the wrong ones. That is the whole job, and the library does it without asking the app to change shape. Try it on a project with three or more views that people share links to, and watch the back button start behaving. If the app has one view and nobody links to it, skip the router and keep the code plain. The router is worth it when the URL matters, and not a moment sooner.
Further reading
- React Router documentation, https://reactrouter.com/
- React docs, Learn React, https://react.dev/learn
- React docs, Adding Interactivity, https://react.dev/learn/adding-interactivity
Originally published at https://practicaldevblog.blogspot.com/2026/11/react-router-pages-that-change-without.html
Top comments (0)