Skip to main content

πŸ› οΈ Configuring Routes and Parameters

Real apps outgrow a flat list of static routes. This lesson digs into the matching engine behind React Router β€” optional and wildcard parameters, why order matters, validating what users type, the object-based data router, and locking down pages with route guards.

🎯 Learning Objectives

By the end of this lesson, you will be able to:

  • Use optional parameters and splat (*) routes to match flexible URL shapes
  • Explain React Router v6's ranked matching and know when order still matters
  • Validate and convert URL parameters, redirecting cleanly on bad input
  • Define routes as objects with createBrowserRouter and load data with loader/useLoaderData
  • Build protected routes and role-based guards

Estimated Time: 40–50 minutes  β€’  Difficulty: Intermediate

Hands-on: Configure a blog's routes with a slug parameter, a validated numeric id, and a login-guarded dashboard.

In This Lesson

Beyond Basic Routes

In the previous lesson you mapped fixed paths and a single dynamic segment to components. That's plenty for a marketing site. But production apps need to answer harder questions: What if a parameter is optional? What if a URL can be arbitrarily deep, like a file path? Which route wins when two could match? How do I stop unauthenticated users from reaching the dashboard?

All of this is route configuration β€” teaching the router's matching engine exactly which URLs map where, and under what conditions. Getting it right is the difference between an app that feels solid and one that 404s on valid links or leaks protected pages.

πŸ“– Key Terms

Dynamic segment: a :name placeholder that matches one path segment.

Splat / catch-all: a * that matches the rest of the path, however deep.

Route guard: a component that renders its children only when a condition (like "logged in") holds, otherwise redirects.

Optional & Splat Parameters

Multiple dynamic segments

You can chain as many named segments as a URL needs:

<Route path="/users/:userId/posts/:postId" element={<UserPost />} />

function UserPost() {
  const { userId, postId } = useParams();
  return <h1>Post {postId} by user {userId}</h1>;
}

Optional parameters

Since React Router 6.4 you can mark a segment optional with a trailing ?. The same route then matches whether or not the segment is present:

// Matches BOTH "/users" and "/users/123"
<Route path="/users/:userId?" element={<Users />} />

function Users() {
  const { userId } = useParams();
  return userId ? <UserProfile userId={userId} /> : <UsersList />;
}

⚠️ Optional-param syntax is version-sensitive

The :param? syntax landed in React Router 6.4. On older versions the classic workaround is two routes pointing at the same element (/users and /users/:userId). If your optional route silently fails to match, check your installed version first.

Splat (wildcard) routes

A * at the end of a path captures everything remaining, across multiple segments. It's ideal for file browsers, wikis, and docs viewers where the depth is unknown:

<Route path="/files/*" element={<FileViewer />} />

function FileViewer() {
  // The captured remainder lives under the "*" key
  const params = useParams();
  const filePath = params['*'];         // e.g. "docs/report.pdf"
  return <p>Viewing: {filePath}</p>;
}
// URL /files/docs/report.pdf  β†’  filePath === "docs/report.pdf"

πŸ’‘ Worked example β€” a nested document viewer

One splat route and one component can serve a whole folder tree. The component fetches whatever the remainder points at, then renders a folder listing or a document:

<Route path="/docs/*" element={<DocumentViewer />} />

function DocumentViewer() {
  const docPath = useParams()['*'];
  const [doc, setDoc] = useState(null);
  const [status, setStatus] = useState('loading');

  useEffect(() => {
    const controller = new AbortController();
    (async () => {
      setStatus('loading');
      try {
        const res = await fetch(`/api/documents/${docPath}`, { signal: controller.signal });
        if (!res.ok) throw new Error('Not found');
        setDoc(await res.json());
        setStatus('ready');
      } catch (err) {
        if (err.name !== 'AbortError') setStatus('error');
      }
    })();
    return () => controller.abort();
  }, [docPath]);

  if (status === 'loading') return <p>Loading…</p>;
  if (status === 'error')   return <p>Document not found.</p>;

  return doc.type === 'folder'
    ? (
      <ul>
        {doc.contents.map(item => (
          <li key={item.name}>
            <Link to={`/docs/${docPath}/${item.name}`}>{item.name}</Link>
          </li>
        ))}
      </ul>
      )
    : <article>{doc.body}</article>;
}

This one definition handles /docs/react (a folder), /docs/react/routing (a subfolder), and /docs/react/routing/params.md (a document) alike.

Route Ranking & Order

Here's a common source of confusion. In React Router v5, routes matched strictly top-to-bottom, so order was everything. In v6, <Routes> uses a ranking algorithm: it scores every route by specificity and renders the best match regardless of source order. A static segment outranks a dynamic one, which outranks a splat.

flowchart TD URL["URL: /products/special"] --> R{Routes ranks all matches} R --> A["/products/special (static β€” highest)"] R --> B["/products/:id (dynamic β€” medium)"] R --> C["/products/* (splat β€” lowest)"] A --> Win["βœ… Renders SpecialProducts"] B -.-> Skip1["skipped"] C -.-> Skip2["skipped"]

So the following renders correctly no matter how you order the three routes β€” v6 picks the static /products/new for that URL, the dynamic one for /products/123, and the splat for anything deeper:

<Routes>
  <Route path="/products/*" element={<ProductCatchAll />} />
  <Route path="/products/:id" element={<ProductDetail />} />
  <Route path="/products/new" element={<NewProduct />} />
</Routes>
{/* v6 still routes /products/new β†’ NewProduct, thanks to ranking */}

⚠️ Order still matters in two cases

  • Equal-rank ties: when two routes score the same, the earlier one wins β€” so genuinely ambiguous routes are still order-sensitive.
  • Manual arrays with useRoutes or the data router: the ranking applies within a single <Routes>/route array, but readability still benefits from ordering specific β†’ dynamic β†’ splat.

Bottom line: rely on ranking, but keep authoring routes most-specific-first. It matches how you think and avoids surprises.

Validating Parameters

URL parameters arrive as untrusted strings. A user can hand-edit the address bar, a crawler can request nonsense, and a stale link can point at a deleted record. Validate before you use.

Convert and range-check

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

function ProductDetail() {
  const { id } = useParams();
  const numericId = Number(id);

  if (!Number.isInteger(numericId) || numericId <= 0) {
    return <p role="alert">Invalid product ID.</p>;
  }
  // …safe to use numericId
}

Redirect invalid input

When a parameter must match a format (say, a UUID), redirect to a not-found page rather than rendering a broken view. Do the redirect in an effect so you don't call navigate during render:

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

const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;

function UserProfile() {
  const { userId } = useParams();
  const navigate = useNavigate();
  const valid = UUID_RE.test(userId);

  useEffect(() => {
    if (!valid) navigate('/not-found', { replace: true });
  }, [valid, navigate]);

  if (!valid) return null; // redirecting
  // …render the profile
}

βœ… Even better: validate in a loader

With the data router (next section), you can validate a parameter before the component renders and throw a response to trigger an error boundary. That keeps components focused on displaying data, not policing it.

The Object-Based Data Router

Defining routes in JSX is readable but limited. For larger apps, React Router 6.4+ recommends the data router: routes are plain objects passed to createBrowserRouter, and each route can declare a loader (to fetch data before rendering) and an action (to handle form submissions). This is the same architecture Remix popularized, now built into React Router.

// router.jsx
import { createBrowserRouter } from 'react-router-dom';
import RootLayout from './layouts/RootLayout';
import Home from './pages/Home';
import Products from './pages/Products';
import ProductDetail from './pages/ProductDetail';
import NotFound from './pages/NotFound';

// A loader runs before the route renders; its return value feeds useLoaderData
async function productsLoader() {
  const res = await fetch('/api/products');
  if (!res.ok) throw new Response('Failed to load products', { status: res.status });
  return res.json();
}

async function productLoader({ params }) {
  const res = await fetch(`/api/products/${params.productId}`);
  if (!res.ok) throw new Response('Not found', { status: 404 });
  return res.json();
}

export const router = createBrowserRouter([
  {
    path: '/',
    element: <RootLayout />,
    errorElement: <NotFound />,   // catches thrown responses/errors below
    children: [
      { index: true, element: <Home /> },
      { path: 'products', element: <Products />, loader: productsLoader },
      { path: 'products/:productId', element: <ProductDetail />, loader: productLoader },
    ],
  },
]);
// main.jsx
import { StrictMode } from 'react';
import { createRoot } from 'react-dom/client';
import { RouterProvider } from 'react-router-dom';
import { router } from './router';

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

Components read loader data with useLoaderData β€” no useEffect, no loading flags, no race conditions. The router fetches before rendering and shows the errorElement if the loader throws:

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

export default function Products() {
  const products = useLoaderData(); // already fetched by productsLoader
  return (
    <ul>
      {products.map(p => (
        <li key={p.id}><Link to={`/products/${p.id}`}>{p.name}</Link></li>
      ))}
    </ul>
  );
}

Generating routes dynamically

Because routes are just data, you can build them programmatically β€” for example, one route per category:

const categories = ['electronics', 'clothing', 'books'];

const categoryRoutes = categories.map(name => ({
  path: `category/${name}`,
  element: <CategoryPage category={name} />,
}));

const children = [
  { index: true, element: <Home /> },
  ...categoryRoutes,
  { path: '*', element: <NotFound /> },
];

πŸ“– JSX routes vs. object routes β€” which to choose?

JSX <Routes>Object data router
Zero config, great for small appsUnlocks loaders, actions, deferred data
Routes live where they're usedRoutes centralized and easy to generate
Data fetching via useEffectData fetched before render, fewer spinners
Familiar and declarativeRecommended for new, larger apps

Rule of thumb: prototypes and simple sites can stay on JSX routes; anything with meaningful data fetching benefits from the data router.

Protected Routes & Guards

Most apps restrict some pages to signed-in users, and some to specific roles. The classic pattern is a small guard component that checks auth state and either renders its children or redirects with <Navigate>.

// components/ProtectedRoute.jsx
import { Navigate, useLocation } from 'react-router-dom';
import { useAuth } from '../context/AuthContext';

export default function ProtectedRoute({ children }) {
  const { user } = useAuth();
  const location = useLocation();

  if (!user) {
    // Remember where they were headed so login can send them back
    return <Navigate to="/login" state={{ from: location }} replace />;
  }
  return children;
}
<Routes>
  <Route path="/" element={<Home />} />
  <Route path="/login" element={<Login />} />
  <Route
    path="/dashboard"
    element={
      <ProtectedRoute>
        <Dashboard />
      </ProtectedRoute>
    }
  />
</Routes>

After login, read location.state?.from to bounce the user back to their original destination:

function Login() {
  const navigate = useNavigate();
  const location = useLocation();
  const from = location.state?.from?.pathname ?? '/dashboard';

  async function handleSubmit(e) {
    e.preventDefault();
    await signIn(/* … */);
    navigate(from, { replace: true });
  }
  // …
}

Role-based guards

Extend the same idea to check roles, redirecting to an "unauthorized" page when the user is logged in but lacks permission:

function RoleRoute({ children, allow }) {
  const { user } = useAuth();
  const location = useLocation();

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

  const permitted = allow.some(role => user.roles.includes(role));
  if (!permitted) return <Navigate to="/unauthorized" replace />;

  return children;
}

// Usage
<Route path="/admin" element={<RoleRoute allow={['admin']}><AdminDashboard /></RoleRoute>} />
<Route path="/reports" element={<RoleRoute allow={['admin', 'analyst']}><Reports /></RoleRoute>} />

⚠️ Client guards are UX, not security

A route guard hides a page in the browser, but anyone can read your JavaScript and call your API directly. Always enforce authentication and authorization on the server for every protected endpoint. The client guard just prevents an empty or broken screen for legitimate users.

πŸ’‘ Guarding at the layout level

With nested routes (next lesson), you can put the guard on a parent layout route so every child inherits protection automatically β€” no need to wrap each page. You'll see that pattern in "Nested Routes and Layout Patterns."

Hands-on Exercise

πŸ‹οΈ Configure a blog's routes

Objective: combine dynamic params, validation, and a guard.

Requirements:

  1. Route /blog lists posts; /blog/:slug shows one post by its slug.
  2. Also support /blog/id/:postId, validating that postId is a positive integer β€” render an error message for anything else.
  3. Add /dashboard behind a ProtectedRoute that redirects to /login when there's no user.
  4. After a mock login, send the user back to wherever they were headed via location.state.from.
πŸ’‘ Hint

Fake auth with a tiny context: const [user, setUser] = useState(null) exposed through useAuth. In the id route, compute Number(postId) and guard with Number.isInteger(n) && n > 0 before rendering.

βœ… Solution sketch
import { BrowserRouter, Routes, Route, Navigate, useLocation, useNavigate, useParams } from 'react-router-dom';
import { createContext, useContext, useState } from 'react';

const AuthContext = createContext(null);
const useAuth = () => useContext(AuthContext);

function ProtectedRoute({ children }) {
  const { user } = useAuth();
  const location = useLocation();
  return user ? children : <Navigate to="/login" state={{ from: location }} replace />;
}

function PostById() {
  const n = Number(useParams().postId);
  if (!Number.isInteger(n) || n <= 0) return <p role="alert">Invalid post id.</p>;
  return <h1>Post #{n}</h1>;
}

function Login() {
  const { setUser } = useAuth();
  const navigate = useNavigate();
  const from = useLocation().state?.from?.pathname ?? '/dashboard';
  return (
    <button onClick={() => { setUser({ name: 'Ray' }); navigate(from, { replace: true }); }}>
      Log in
    </button>
  );
}

export default function App() {
  const [user, setUser] = useState(null);
  return (
    <AuthContext.Provider value={{ user, setUser }}>
      <BrowserRouter>
        <Routes>
          <Route path="/blog" element={<h1>All posts</h1>} />
          <Route path="/blog/id/:postId" element={<PostById />} />
          <Route path="/blog/:slug" element={<h1>Post by slug</h1>} />
          <Route path="/login" element={<Login />} />
          <Route path="/dashboard" element={<ProtectedRoute><h1>Dashboard</h1></ProtectedRoute>} />
        </Routes>
      </BrowserRouter>
    </AuthContext.Provider>
  );
}

Note how /blog/id/:postId and /blog/:slug coexist safely β€” v6 ranking prefers the more specific /blog/id/… path.

🎯 Quick Quiz

Question 1: In React Router v6, given routes /products/new, /products/:id, and /products/* declared in that or any order, what renders for the URL /products/new?

Question 2: What is the main advantage of a route loader in the data router over fetching inside useEffect?

Question 3: Why is a client-side ProtectedRoute not enough to secure sensitive data?

Best Practices

βœ… Do

  • Author routes most-specific β†’ dynamic β†’ splat, even though v6 ranks for you β€” it reads clearly and handles ties.
  • Validate and convert every parameter; redirect or show an error on bad input.
  • Reach for the object data router (loaders/actions) once data fetching gets non-trivial.
  • Enforce auth on the server; treat client guards as UX polish.

❌ Don't

  • Don't call navigate during render β€” do redirects in an effect, or return <Navigate>.
  • Don't assume :param? works on every version β€” it needs React Router 6.4+.
  • Don't trust path or query values as valid, in-range, or existing.
  • Don't scatter identical guards across dozens of routes when a parent layout guard would do (see next lesson).

Summary & Quiz

πŸŽ‰ Key Takeaways

  • Optional (:param?) and splat (*) segments let one route match flexible URL shapes; the splat's remainder lives under the '*' key.
  • React Router v6 ranks matches by specificity, so order rarely breaks matching β€” but authoring specific-first keeps ties predictable.
  • Always validate and convert parameters; redirect cleanly in an effect on bad input.
  • The object data router (createBrowserRouter + loader/useLoaderData) fetches before render and centralizes route config.
  • Guard components restrict pages by auth or role β€” but real security lives on the server.

πŸ“š Further Reading

πŸš€ What's Next?

Next: Nested Routes and Layout Patterns, where the <Outlet> component lets parent routes wrap children in shared layouts β€” the cleanest way to apply that layout-level guard we just mentioned.

πŸŽ‰ Nicely done!

You can now shape the router's matching engine to any URL scheme your app needs, safely.