π οΈ 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
createBrowserRouterand load data withloader/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.
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
useRoutesor 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 apps | Unlocks loaders, actions, deferred data |
| Routes live where they're used | Routes centralized and easy to generate |
Data fetching via useEffect | Data fetched before render, fewer spinners |
| Familiar and declarative | Recommended 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:
- Route
/bloglists posts;/blog/:slugshows one post by its slug. - Also support
/blog/id/:postId, validating thatpostIdis a positive integer β render an error message for anything else. - Add
/dashboardbehind aProtectedRoutethat redirects to/loginwhen there's no user. - 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
navigateduring 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
- Route configuration reference
- createBrowserRouter & the data router
- Route loaders
- The Navigate component
π 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.