Lesson 6 of 12

50% Complete

Routing with React Router

Learn how to implement client-side routing in React applications using React Router. Master navigation, route parameters, and protected routes.

In this lesson, you'll learn how to implement client-side routing in React using React Router to turn your app into a single-page application. We'll add a reusable navigation system (using React Router's <Link> and <NavLink>) to our component library project, and build a simple PrivateRoute component to protect certain pages. By the end, you'll know how to set up React Router, define routes (including dynamic URL parameters), navigate programmatically, nest routes for layout, and handle "Page Not Found" cases.

Step-by-step: build it right

Setting up React Router and basic routing

First, install React Router (specifically the react-router-dom package) and import the router components in your app. You need to wrap your application with a router (usually <BrowserRouter>) to provide routing context, then define your routes inside a <Routes> container. Each <Route> maps a URL path to a React component to render. For example, let's set up two simple routes in our App: a homepage and an about page.

Loading syntax highlighting...

In the code above, visiting "/" will render the <HomePage> component, and "/about" will render <AboutPage>. The <BrowserRouter> uses the HTML5 history API to keep the UI in sync with the URL. The <Routes> component ensures only the matching route's element is displayed. (In React Router v6, <Routes> replaces the older <Switch> and all routes are exclusive by default—no need for an exact prop.)

⚠️ Always wrap your routes in a Router:

"useRoutes() may be used only in the context of a <Router>"

Adding navigation links for SPA behavior

Right now, to see the different pages, you'd have to manually type the URL. Let's add a navigation menu using React Router's <Link> (or <NavLink>). The <Link> component creates an anchor that updates the URL without causing a full page reload. This is crucial for maintaining state and a smooth user experience in a SPA. By contrast, a normal <a href="..."> would trigger the browser to reload the page from the server, losing any React state.

We'll create a simple NavBar component with links to our routes:

Loading syntax highlighting...

Here we use <NavLink> for navigation. <NavLink> works like <Link>, but adds an "active" class automatically when the current URL matches the link. We include an end attribute on the Home link so that it's only marked active on the exact "/" path (otherwise "/about" would also activate the Home link, since "/" is a prefix of "/about"). You can style the .active class in CSS to highlight the current page.

Add <NavBar /> to your app (e.g. inside the BrowserRouter, above the Routes) and now you have client-side navigation. Clicking "About" will change the URL to "/about" and render the About page instantly, with no flash or full reload.

⚠️ Use <Link> instead of <a> for internal navigation.

Dynamic routes with URL parameters

So far, our routes have been static. Often you'll have routes that include dynamic parameters, like an ID or name. For example, in Ollie's component library app, suppose we want a page for each UI component in the catalog: the URL might be /components/:componentName. The :componentName portion is a route parameter that will match any value in that segment of the URL.

Defining a dynamic route is easy – just use a : prefix in the path. Then inside the component for that route, use React Router's useParams hook to access the actual value:

Loading syntax highlighting...

If the user navigates to /components/Button, the <ComponentPage> will render and useParams() gives { name: "Button" }. We could use that to fetch data about the "Button" component or simply display details. In our example, it just renders text using the name. Similarly, a route like /user/:userId could capture a userId – going to /user/42 would give you userId = "42" in your component.

To navigate to dynamic routes, you can build the URL with the parameter. For instance, <Link to="/components/ToggleSwitch">Toggle Switch</Link> would take the user to the ToggleSwitch component's page. React Router will parse the URL and load the correct component with useParams providing { name: "ToggleSwitch" }.

What about query strings? For reading query parameters (e.g. ?search=hello), React Router provides a useSearchParams hook that works similar to the browser's URLSearchParams API. This lets you get query string values and even update them. Query params don't affect which component is loaded (they're not part of the route path matching), but you can use them inside your components for things like filtering lists. For now, just note that dynamic URL segments (as in :id) are handled with useParams, while query strings can be accessed with useSearchParams.

Programmatic navigation with useNavigate

In a real app, navigation isn't always triggered by clicking links. You might want to redirect the user in code – for example, after a form submission, or a few seconds after showing a notification. React Router's useNavigate hook gives you a function to navigate by code (imperatively).

Let's say our homepage has a button that should take the user to the dashboard page when clicked:

Loading syntax highlighting...

When the button is clicked, we call navigate('/dashboard'), which changes the route to "/dashboard". This causes the router to render the Dashboard page component (just as if the user had clicked a <Link> to "/dashboard"). No full reload occurs, and we didn't have to display a link – the navigation happened programmatically. You can use this for many cases: redirecting after login, stepping through a multi-step wizard, etc.

React Router also provides a <Navigate> component for redirects during rendering. For example, you might have a route that should immediately redirect older URLs to a new URL, or redirect from the root path to a default sub-route. The <Navigate> component can be returned from inside a route's element to perform a redirect. A common use is to protect authenticated pages, which we'll see next.

Protected routes (route guards)

Some parts of your app should only be accessible to certain users (like logged-in members). Protected routes allow you to block or redirect users who aren't authorized. In React Router, a typical pattern is to create a wrapper component (often called PrivateRoute or RequireAuth) that checks login state and either renders the protected page or redirects to a login screen.

First, let's set up a simple auth state. For this lesson, we'll just use a boolean (in a real app, you'd check an auth context or Redux store, and have more complex logic). We'll also make a "Login" page that can toggle this auth state for demonstration.

Loading syntax highlighting...

In the code above, <PrivateRoute> is used to wrap the <Dashboard> element. If the user isn't authenticated (isAuthenticated is false), <Navigate to="/login" /> will immediately redirect them to the Login page. If they are authenticated, it simply renders the children (the Dashboard). This way, the Dashboard route is guarded – only reachable when logged in.

The Login page component can call setIsAuthenticated(true) when the user "logs in" (e.g., after a form submission or clicking a dummy "Login" button for our example). You might also use useLocation to get the page the user originally wanted and redirect back there after login, but that's an extra detail. The key idea is that route guards combine React Router navigation (like <Navigate>) with app state to control access.

⚠️ Don't forget to protect all relevant routes. If you have multiple protected pages, remember to wrap each of those routes with your <PrivateRoute> (or use an outlet pattern to protect a whole group of routes). Any route not wrapped will be accessible without authentication, even if you didn't intend it.

Nested routes and layout components

Large apps often have sections or layouts where multiple pages share a common UI structure. React Router allows nested routes, meaning you can have a parent route that renders some layout and child routes that render into that layout. This helps avoid repeating code for headers, sidebars, or other shared elements on related pages.

For example, suppose our Dashboard has a couple of sub-pages: a main overview and a settings page. We can set up routes like this:

Loading syntax highlighting...

Here, we have a parent route for /dashboard that uses a DashboardLayout component, and two child routes. The child with index has no path of its own, so it will render at /dashboard (this acts as the default content). The second child will render at /dashboard/settings. Notice the nested <Route> elements: this structure means the child routes will render inside the parent's element.

So what does DashboardLayout look like? It should include an <Outlet /> where child route content goes:

Loading syntax highlighting...

Now the Dashboard pages work like this: the layout's <h1> title and any common UI (like nav links, sidebar, etc.) show up, and depending on the URL, either <DashboardHome> or <SettingsPage> will be inserted in the <Outlet> spot. The URL /dashboard matches the parent and the index child, so it might show a welcome message or overview. The URL /dashboard/settings matches the "settings" child, so the layout renders and inside it we get the SettingsPage content. This nesting keeps the URL paths clean (no need to repeat "dashboard" in every child route's path) and it ensures the Dashboard layout is consistently applied.

⚠️ When using nested routes, make sure to include <Outlet> in the parent component. If you forget the <Outlet />, the child routes have nowhere to render, and you'll be scratching your head wondering why the page is blank. (The parent route renders, but the child content won't appear.)

Handling missing pages (404 errors)

Finally, let's handle the case of an unknown route. In a multi-page app, if the user enters an unrecognized URL, we should show a friendly "404: Not Found" message. With React Router, you can define a catch-all route using the path "*" which matches any URL that wasn't matched by earlier routes.

Add a <Route path="*"> at the end of your routes list, pointing to a <NotFoundPage> component:

Loading syntax highlighting...

Now, if the user tries to go to /some/random/path that we haven't defined, React Router will fall back to this "*" route. Our NotFoundPage displays a message and a link to navigate back to the home page. This improves the user experience by handling mistakes or outdated links gracefully. (Note: In React Router v6, route matching is order-sensitive for overlapping patterns, but a wildcard "*" will match only if nothing else does. It's still a good practice to place the catch-all route last.)

Best practices & common mistakes – avoid dumb bugs

  • Wrap your app with <BrowserRouter>: If you don't wrap your app (or at least your <Routes>) with a router component, none of the routes or links will work. This is a common oversight when setting up routing for the first time.
  • Use React Router links instead of anchors: For internal navigation, always use <Link> or <NavLink>. Using a raw <a href="..."> will trigger a full page reload, wiping out React state and defeating the purpose of client-side routing.
  • Don't mix up v6 API with older examples: React Router v6 simplified the API – it uses <Routes> (not <Switch>), and you pass components via an element prop (not component or render props). Using the wrong API will either not work or throw errors. Update older code snippets to the new syntax.
  • Match your route params and useParams: Make sure the parameter names in your route path and your call to useParams() agree exactly. If you define <Route path="/user/:id"> but destructure useParams() as const { userId } = useParams(), you'll get undefined. The names must match (e.g. :id and then use params.id).
  • Include <Outlet> in layout components: As mentioned, forgetting to put <Outlet /> in a parent route component is a common mistake when using nested routes. No <Outlet> means no child content will render – and no error will be thrown, which makes it tricky to debug. Always add an outlet to your layout components.

Recap

  • Set up React Router by wrapping your app in a router (like <BrowserRouter>) and defining routes with <Routes> and <Route> elements.
  • Use <Link> and <NavLink> for navigation links to avoid full page reloads and preserve state in your single-page app.
  • Define dynamic routes with URL parameters (e.g. /:id) and access them in your components using the useParams() hook.
  • Perform navigations in code with the useNavigate hook – for example, redirect the user after an action without needing a clickable link.
  • Implement protected routes by creating a wrapper (e.g. <PrivateRoute>) that checks authentication and uses <Navigate> to redirect if needed.
  • Organize your app with nested routes and layout components: parent routes render layouts and an <Outlet> for child routes, enabling shared UI across related pages.
  • Provide a fallback <Route path="*"> to catch unknown URLs and display a "Not Found" page, so users aren't left with a blank screen.

With these skills, Ollie the Owl (and you) can confidently add multiple pages to any React app without breaking the seamless single-page experience. Happy routing!

Next: Testing React Applications →