Handling Optional Dynamic Segments in Routing
After reading this you will be able to register a single route whose trailing segment may be present or absent, resolve a deterministic default when it is omitted, and prove the behaviour holds for both /products and /products/electronics without a duplicate matcher or a stray 404.
← Back to Dynamic Route Segments
Prerequisites
Core Concept
An optional dynamic segment is a named parameter that may or may not appear in the URL, so a single registered pattern matches both the bare path and the parameterised one. Rather than registering /products and /products/:category as two separate routes — which invites a precedence conflict where the broad pattern swallows the specific one — you mark the segment optional (:category? in most parsers) and let one matcher cover both. The cost of that convenience is that the captured value is string | undefined, and any code downstream that assumes a string will crash the moment the segment is missing.
The discipline this page teaches is to never let undefined escape the resolution boundary. You compile the pattern so the trailing slash is also optional, you assign a fallback value at the loader or guard layer, and you normalise the URL before it enters the history stack so that /products and /products/ never produce two distinct route signatures. Getting this right depends on understanding how route matching algorithms order and compile patterns, because greedy compilation is the root of nearly every optional-segment failure.
It helps to see why the naive two-route approach degrades. When you register /products and /products/:category side by side, each path acquires its own matcher, its own loader, and its own place in the precedence list. The two now drift apart: one resolves a category and one does not, so shared logic forks, and the order in which they were registered silently decides which one wins for an ambiguous URL. Collapsing them into one optional pattern removes that drift entirely — there is a single source of truth for what “the products route” means, and the only branch left is the presence or absence of the value, handled once. This is also why optional segments compose cleanly with deeper structures like the ones described under Regex Route Matching in Vanilla JS: an optional group is just one more piece of a single compiled expression rather than a fork in your route table.
Implementation
The pattern below is framework-agnostic. It compiles an optional segment to a regex, captures the value safely, and applies a deterministic default so no consumer ever sees undefined.
// TypeScript 5.x — framework-agnostic
interface OptionalMatch {
category: string; // never undefined after resolution
}
// Build a matcher where BOTH the segment and its leading slash are optional.
// The (?:...)? wrapper is what lets "/products" match without a trailing slash,
// while ([^/]+) still captures a real value when one is present.
function compileOptional(): RegExp {
return /^\/products(?:\/([^/]+))?\/?$/;
}
function matchProducts(pathname: string): OptionalMatch | null {
const result = compileOptional().exec(pathname);
if (result === null) return null; // truly unmatched — let the catch-all handle it
// result[1] is undefined for "/products" and a string for "/products/books".
// Coalescing here is the single boundary where the default is decided.
const category = result[1] ?? "all";
return { category };
}
// All three of these resolve to the same logical route — no duplicate matcher,
// no 404 on the bare path.
matchProducts("/products"); // { category: "all" }
matchProducts("/products/"); // { category: "all" }
matchProducts("/products/books"); // { category: "books" }
matchProducts("/orders"); // null
Two details in that code carry most of the weight. The first is (?:\/([^/]+))?: the non-capturing wrapper holds both the slash and the value, so when neither is present the regex still succeeds against /products instead of demanding a separator that is not there. The second is the ?? "all" coalescing — placed at exactly one point so that every later read of category is guaranteed a string. Scatter that fallback across components and you reintroduce the very undefined leaks the single boundary was meant to eliminate.
If you are on React Router, the native ? marker does the compilation for you; your job shrinks to coalescing the default and normalising the URL before navigation. The same principle applies in Vue Router and SvelteKit: each exposes its own optional-segment syntax, but in all three the parameter you read back is nullable, and the safe pattern is identical — resolve the default once, then treat the value as present everywhere downstream.
// react-router-dom v6.22
import { useParams, useNavigate } from "react-router-dom";
// Route registered once as: <Route path="/products/:category?" element={<List />} />
function List() {
const { category } = useParams<{ category?: string }>();
const resolved = category ?? "all"; // collapse undefined at the read site
return <ProductGrid filter={resolved} />;
}
// Normalise before pushing so "/products/" and "/products" don't split history.
function useNormalisedNavigate() {
const navigate = useNavigate();
return (path: string) =>
navigate(path.replace(/\/+$/, "") || "/", { replace: false });
}
Verification
Drive both forms of the URL and assert they land on the same resolved state. This Playwright check confirms the bare path renders content rather than a 404, and that the optional value flows through when present.
// @playwright/test v1.4x
import { test, expect } from "@playwright/test";
test("optional segment resolves with and without the parameter", async ({ page }) => {
// Bare path must NOT 404 and must fall back to the default filter.
await page.goto("/products");
await expect(page.getByTestId("active-filter")).toHaveText("all");
// Parameterised path captures the real value.
await page.goto("/products/books");
await expect(page.getByTestId("active-filter")).toHaveText("books");
// Trailing-slash variant must not create a second, broken signature.
await page.goto("/products/");
await expect(page.getByTestId("active-filter")).toHaveText("all");
});
For a quick DevTools check without a test runner, paste compileOptional().exec("/products") into the console: index 1 of the result should be undefined, confirming the capture group is genuinely optional rather than failing the whole match.
Gotchas
- Greedy trailing slash:
/^\/products\/([^/]+)?\/?$/looks correct but the inner?on the capture lets([^/]+)match an empty string against/products/, which some engines treat differently from a true miss. Wrap the whole segment including its slash in(?:\/([^/]+))?so the absence is unambiguous. - Precedence over a static sibling: if you also register a literal
/products/new, place it before the optional pattern. A greedy optional matcher will otherwise capturenewas a category — a classic ordering bug covered in Route Matching Algorithms. - History split on the slash: pushing
/products/and/productsas separate entries pollutes the back stack and breaks scroll restoration. Normalise the path before everypushStatecall. - SSR hydration drift: if the server resolves the default (
all) but the client reads rawundefined, the trees diverge and React throws a hydration error. Apply the same coalescing on both sides.
FAQ
Why does omitting an optional segment trigger a 404 instead of a fallback? Most matchers compile the segment into a required capture group, so a missing value fails the whole pattern and the request falls through to the catch-all handler. Wrapping the segment and its leading slash in an optional non-capturing group, as in (?:\/([^/]+))?, lets the bare path match while still capturing a value when one exists.
What value does an optional parameter hold when the segment is absent? It is undefined, not an empty string, in every mainstream router. Treat the parameter type as string | undefined and coalesce it to a deterministic default (params.category ?? "all") at a single resolution boundary so no consumer downstream ever has to handle the missing case.
How do I stop /products and /products/ from becoming two routes? Normalise the pathname before it enters the history stack by stripping trailing slashes (path.replace(/\/+$/, "") || "/"). This keeps a single canonical signature, prevents duplicate back-stack entries, and avoids the duplicate-content problem search engines flag.
Does adding an optional segment slow down route matching? Negligibly. An optional capture group adds one branch to the compiled regex, which is dwarfed by render and data-fetch cost. A correctly optional pattern is usually faster overall because it removes the redundant second matcher and the fallback check it would have triggered.
Can an optional segment break SSR hydration? Yes, if the server and client resolve the default differently. Render the same coalesced value on both sides so the markup matches; a server that emits all while the client reads undefined produces a hydration mismatch that can tear down the component tree.
Related
- Dynamic Route Segments — the parent pattern for named parameters and how routers parse them.
- Route Matching Algorithms — how matchers order and compile patterns, which governs optional-segment precedence.
- Regex Route Matching in Vanilla JS — building the optional capture groups by hand without a framework.