Skip to main content
Published / updated

Routing with React Router

Before you start

You need: forms (Article 04).

Time: about 45 minutes, plus the practice.

Learning objective

Build a routed application with nested layouts, lazy-loaded features, and URL state that survives a refresh and a shared link.

Topics

  • Setup
  • Defining routes
  • Link and NavLink
  • Route parameters
  • Query parameters
  • Nested routes and layouts
  • Programmatic navigation
  • Lazy loading
  • Protected routes
  • Deployment configuration

Setup

npm install react-router-dom

React Router is not part of React — routing is one of the things React leaves to you. It is the near-universal choice.

// main.tsx
import { createBrowserRouter, RouterProvider } from 'react-router-dom';
import { router } from './router';

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

Defining routes

// router.tsx
import { createBrowserRouter, Navigate } from 'react-router-dom';

export const router = createBrowserRouter([
{
path: '/',
element: <RootLayout />,
errorElement: <ErrorPage />,
children: [
{ index: true, element: <Navigate to="/students" replace /> },

{ path: 'login', element: <LoginPage /> },

{
path: 'students',
element: <RequireAuth><Outlet /></RequireAuth>,
children: [
{ index: true, element: <StudentListPage /> },
{ path: 'new', element: <StudentFormPage /> },
{
path: ':publicId',
element: <StudentShell />,
children: [
{ index: true, element: <Navigate to="overview" replace /> },
{ path: 'overview', element: <StudentOverview /> },
{ path: 'results', element: <StudentResults /> },
{ path: 'fees', element: <StudentFees /> }
]
},
{ path: ':publicId/edit', element: <StudentFormPage /> }
]
},

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

createBrowserRouter is the data-router API and the current recommendation. The older <BrowserRouter> with JSX <Routes> still works and appears in most existing projects.

React Router ranks routes by specificity, not declaration order. students/new wins over students/:publicId automatically — unlike Angular, where order decides. Still declare them in a sensible order for readability.

errorElement catches errors thrown during rendering or data loading in that subtree — the routing equivalent of an error boundary, and it prevents a blank white page.

import { Link, NavLink } from 'react-router-dom';

<Link to="/students">Students</Link>
<Link to={`/students/${student.publicId}`}>Details</Link>
<Link to="/students" state={{ from: 'dashboard' }}>Students</Link>

<NavLink
to="/students"
end
className={({ isActive }) => isActive ? 'nav-link active' : 'nav-link'}
>
Students
</NavLink>

Use Link, never <a href>. An href triggers a full page reload — the whole application restarts and all state is lost. This is the most visible routing mistake there is.

NavLink gives an isActive flag. end matters: without it, /students stays active on /students/new, because it matches as a prefix.

For accessibility, mark the current page:

<NavLink to="/students" end aria-current={({ isActive }) => isActive ? 'page' : undefined}>
Students
</NavLink>

Route parameters

import { useParams } from 'react-router-dom';

export function StudentDetail() {
const { publicId } = useParams<{ publicId: string }>();

const { student, loading, error } = useStudent(publicId!);

if (loading) return <p className="muted">Loading…</p>;
if (error) return <ErrorMessage message={error} />;
if (!student) return <p>Student not found.</p>;

return <StudentDetailView student={student} />;
}

useParams returns string | undefined for each parameter — the route guarantees it, but TypeScript does not know that.

The component is reused when navigating between two ids. React does not remount it, so any effect keyed on publicId must have it in the dependency array:

useEffect(() => {
const controller = new AbortController();

load(publicId!, controller.signal);

return () => controller.abort();
}, [publicId]);

Omitting the dependency shows the previous student forever — the same bug as reading a route snapshot once.

To force a full remount, key the component:

{ path: ':publicId', element: <StudentDetail key={publicId} /> }

Cleaner than an effect that resets six state variables and forgets the seventh.

Query parameters

Filters, paging and sorting belong here — they are part of the page's address.

import { useSearchParams } from 'react-router-dom';

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

const term = searchParams.get('term') ?? '';
const className = searchParams.get('className') ?? '';
const page = Number(searchParams.get('page') ?? '1');

function updateSearch(nextTerm: string) {
setSearchParams(prev => {
const next = new URLSearchParams(prev);

if (nextTerm) {
next.set('term', nextTerm);
} else {
next.delete('term');
}

next.set('page', '1');
return next;
}, { replace: true });
}

const { students, loading, error } = useStudents(term, className, page);
// …
}

URL state survives a refresh, is shareable, and works with the back button. State stored only in a component is lost on refresh and cannot be bookmarked — and users do share filtered URLs.

{ replace: true } on a live search avoids a history entry per keystroke, which otherwise makes the back button unusable.

Building the next params from the previous ones preserves parameters you are not changing.

Nested routes and layouts

import { Outlet } from 'react-router-dom';

export function RootLayout() {
return (
<div className="layout">
<header>
<nav aria-label="Main">
<NavLink to="/students" end>Students</NavLink>
<NavLink to="/fees">Fees</NavLink>
</nav>
</header>

<main id="main">
<Outlet />
</main>

<footer>© NexCoding Academy</footer>
</div>
);
}

<Outlet /> is where the matched child renders. The layout is not re-created when navigating between children, so its state and any data it loaded persist.

export function StudentShell() {
const { publicId } = useParams<{ publicId: string }>();
const { student } = useStudent(publicId!);

return (
<>
<h1>{student?.name}</h1>

<nav aria-label="Student sections">
<NavLink to="overview">Overview</NavLink>
<NavLink to="results">Results</NavLink>
<NavLink to="fees">Fees</NavLink>
</nav>

<Outlet context={{ student }} />
</>
);
}
// In a child
const { student } = useOutletContext<{ student: Student | null }>();

useOutletContext passes data from a layout to its children without prop drilling or context.

The shell fetches the student once; switching tabs does not refetch.

Programmatic navigation

import { useNavigate, useLocation } from 'react-router-dom';

export function StudentForm() {
const navigate = useNavigate();
const location = useLocation();

async function handleSubmit() {
await studentApi.create(values);

navigate('/students?saved=1');
}

function handleCancel() {
navigate(-1); // back
}

function goToDetail(publicId: string) {
navigate(`/students/${publicId}`, { replace: true });
}
}
CallEffect
navigate('/students')Push a new entry
navigate('/students', { replace: true })Replace the current entry
navigate(-1)Back
navigate('/x', { state: { … } })Pass state not visible in the URL

state does not survive a refresh. It is right for a transient success banner, wrong for anything the page needs to render.

const location = useLocation();
const savedMessage = (location.state as { message?: string } | null)?.message;

Use replace after a save, so the back button does not return the user to a form they have already submitted.

Lazy loading

import { lazy, Suspense } from 'react';

const StudentListPage = lazy(() => import('./features/students/StudentListPage'));
const FeeReportPage = lazy(() => import('./features/fees/FeeReportPage'));

export const router = createBrowserRouter([
{
path: '/',
element: <RootLayout />,
children: [
{
path: 'students',
element: (
<Suspense fallback={<p className="muted">Loading…</p>}>
<StudentListPage />
</Suspense>
)
}
]
}
]);

Put the <Suspense> boundary in the layout to avoid repeating it:

export function RootLayout() {
return (
<main id="main">
<Suspense fallback={<p className="muted">Loading…</p>}>
<Outlet />
</Suspense>
</main>
);
}

lazy() requires a default export from the imported module. A named export gives "Element type is invalid", which is a confusing message for a missing default.

Lazy-load every feature area. The initial bundle then holds only the shell and the first route. Verify it in the Network tab: navigating to a lazy route should download a new .js chunk.

Protected routes

export function RequireAuth({ children }: { children: React.ReactNode }) {
const { isAuthenticated } = useAuth();
const location = useLocation();

if (!isAuthenticated) {
return <Navigate to="/login" state={{ from: location }} replace />;
}

return <>{children}</>;
}
export function RequireRole({ roles, children }: { roles: string[]; children: React.ReactNode }) {
const { user } = useAuth();

if (!user) {
return <Navigate to="/login" replace />;
}

if (!roles.includes(user.role)) {
return <Navigate to="/access-denied" replace />;
}

return <>{children}</>;
}
{
path: 'fees',
element: <RequireRole roles={['Admin', 'Staff']}><Outlet /></RequireRole>,
children: [{ index: true, element: <FeeReportPage /> }]
}

Returning to the attempted page after signing in:

export function LoginPage() {
const navigate = useNavigate();
const location = useLocation();

const from = (location.state as { from?: Location } | null)?.from?.pathname ?? '/students';

async function handleSubmit() {
await login(credentials);
navigate(from, { replace: true });
}
}

replace on the redirect means the back button does not return to the login page after signing in.

A protected route is not a security control. It stops a signed-out user seeing a blank page. Anyone can call the API directly, so the server must enforce every rule independently — the same boundary as everywhere else.

Deployment configuration

/students/a1 exists only in the router. The server has no such file, so a direct request or a refresh returns 404.

location / {
try_files $uri $uri/ /index.html;
}
<!-- IIS: web.config -->
<rewrite>
<rules>
<rule name="React Router">
<match url=".*" />
<conditions logicalGrouping="MatchAll">
<add input="{REQUEST_FILENAME}" matchType="IsFile" negate="true" />
</conditions>
<action type="Rewrite" url="/index.html" />
</rule>
</rules>
</rewrite>
// vercel.json
{ "rewrites": [{ "source": "/(.*)", "destination": "/index.html" }] }

Everything works in npm run dev because Vite does this automatically — which is exactly why the problem is only discovered after deployment. Test it with npm run build && npm run preview before shipping.

Scroll restoration

import { ScrollRestoration } from 'react-router-dom';

export function RootLayout() {
return (
<>
<ScrollRestoration />
<Outlet />
</>
);
}

Scrolls to the top on a new navigation and restores the previous position on a back navigation. Without it, navigating from halfway down a long list to a detail page leaves the user halfway down the new page.

Announcing navigation

A SPA navigation is silent to a screen reader — the page changes with no announcement.

export function RootLayout() {
const location = useLocation();
const [announcement, setAnnouncement] = useState('');

useEffect(() => {
setAnnouncement(document.title);
}, [location.pathname]);

return (
<>
<p role="status" aria-live="polite" className="visually-hidden">{announcement}</p>
<a href="#main" className="skip-link">Skip to main content</a>
<Outlet />
</>
);
}

Set the title per page and announce it. Moving focus to the main heading on navigation is the other common approach.

This is the accessibility failure most SPAs ship with, and it is a handful of lines to fix.

Errors you will hit

What you seeCauseFix
useNavigate() may be used only in the context of a RouterComponent outside the routerMove it inside
Refresh gives a 404Server not configured for client-side routingRewrite all paths to index.html
Component keeps old data on navigationReused across routesAdd a key, or refetch on the param
Nested route renders nothingMissing <Outlet />Add it
A full page reload on link clickUsed <a> instead of <Link>Use <Link>

<a href> reloads the whole application. <Link> navigates without losing state.

Common mistakes

  • <a href> instead of Link
  • NavLink without end, so a parent stays active
  • useParams without the id in an effect's dependencies
  • Filter state in a component instead of the URL
  • No replace on a live search, breaking the back button
  • No replace after a save, so Back returns to the submitted form
  • lazy() on a module with no default export
  • No Suspense boundary around a lazy route
  • Expecting navigate state to survive a refresh
  • Treating a protected route as a security control
  • No SPA fallback on the server
  • No scroll restoration
  • No navigation announcement for screen readers

Practice

The course assignment is build a routed application.

  1. Set up createBrowserRouter with a root layout, students, login and a * fallback.
  2. Change one Link to <a href> and watch the full reload in DevTools.
  3. Add NavLink without end. Navigate to /students/new and confirm Students is still active.
  4. Build the detail page with useParams. Omit publicId from the effect's dependencies, then navigate between two students and confirm the page does not change.
  5. Add the dependency, then add an AbortController and confirm rapid navigation cannot show the wrong student.
  6. Move search and paging into useSearchParams. Refresh and confirm the filter survives.
  7. Copy the filtered URL into a new tab and confirm it opens identically.
  8. Remove { replace: true } from the search update. Type five characters and press Back once.
  9. Build the student shell with child routes and useOutletContext. Switch tabs and confirm the shell does not refetch.
  10. Convert a feature to lazy() with Suspense. Confirm the new chunk in the Network tab.
  11. Use a named export with lazy() and record the error.
  12. Add RequireAuth with from state and confirm sign-in returns you to the attempted page.
  13. Add RequireRole and confirm a Teacher is redirected from /fees. Then call the fees API directly with the Teacher's token.
  14. Add ScrollRestoration and confirm the position is restored on Back.
  15. Add the navigation announcement and test with a screen reader.
  16. Run npm run build && npm run preview, then refresh on /students/a1. Confirm the 404, then configure the fallback.

Exercises 4 and 16 reach production most often.

You can now

  • Build a routed application with nested routes
  • Use <Link> rather than <a>
  • Read route and query parameters
  • Render nested routes with <Outlet />
  • Say why a refresh 404s without server configuration

Review questions

  1. Why does <a href> break a single-page application?
  2. Why must a route parameter appear in an effect's dependency array?
  3. Why do filters belong in the URL rather than component state?
  4. Why does refreshing a deep link 404 in production but not in dev?

Next: API integration