Route Matching
How a path becomes a route: segmentation, patterns, specificity, ranking versus ordering, and the nested match chain that renders a page.
The intent, the obvious build, and why it breaks
Every lesson starts where the work starts: someone wanted an outcome, and the first implementation that comes to mind has a problem.
Given a URL, how does a router decide which route owns it — and why do two routes ever both look right?
A person follows a link to /orders/2024-11/invoice. They expect the one screen that URL describes, not a blank page, not the wrong one, and not a different screen depending on the order somebody registered routes in.
Keep an array of patterns, walk it top to bottom, and render the first one whose regular expression matches the path. It is a lookup table; how complicated can matching a string be.
/orders/new matches /orders/:id because that route was registered first, so the create form never renders and the app tries to fetch an order with the id new.
/orders/newmatches/orders/:idbecause that route was registered first, so the create form never renders and the app tries to fetch an order with the idnew.- Adding a route changes the behaviour of routes nobody touched, because the array position is load-bearing. Reordering imports is now a functional change.
/orders/and/ordersare different strings, so one of them falls through to the catch-all and the user gets a not-found page from a link in your own navigation.- A path segment containing an encoded slash —
/tags/vue%2Frouter— is split into two segments by a naivepath.split('/'), and matches a route it has nothing to do with. - A user refreshes on
/orders/123and the server returns 404, because the server has never heard of that path — only the client router has (Deploying a Frontend). - Two nested layouts both claim the URL, and which one wins depends on whether the router matched breadth-first or depth-first, which the documentation does not say.
What is actually happening
In the browser, not in the framework.
- The router works on
location.pathname, not the whole URL. The query string and the hash are not part of matching in any mainstream router — they are read afterwards (URL Parameters). - The pathname is split into segments on
/, and each segment is percent-decoded *after* splitting. Decoding first is the bug that makes%2Fbehave like a separator. - Each route pattern is also a sequence of segments, each of which is one of a few kinds: static (
orders), dynamic (:id), optional, or wildcard / splat (*), plus an index route meaning "this parent, with nothing after it". - A segment kind implies a specificity. A static segment is more specific than a dynamic one, which is more specific than a wildcard, and specificity is compared segment by segment from the left. That is the whole of route ranking, in every router that ranks.
- Matching a large route table efficiently is a prefix-tree problem, not a list-scan problem: segments share prefixes, so routers commonly compile the table into a radix tree and walk it once per navigation rather than testing every pattern (Trie in DSA).
- Nested routes make the result a chain, not a single winner.
/orders/123/itemscan match a root layout, an orders layout, an order layout and an items view; every one of them renders, each inside the previous one's outlet. - None of this exists on the server unless you put it there. A client route table is a client artifact; the server needs its own rule — usually "serve the app shell for anything that is not a file or an API path" (Deploying a Frontend).
What this makes the browser do
And which of it is avoidable.
- Very little, and that is worth knowing: matching is string work on a table that is usually tens of entries. It is essentially never the reason a navigation feels slow.
- What does cost: constructing the route table at startup, especially when every route module is imported eagerly to be registered. That is bundle work on the critical path, not matching work (Code Splitting).
- Rendering the match chain. A four-deep nested match mounts four component subtrees, and if the parent layouts are re-created rather than reused, the browser lays out and paints the whole page instead of the outlet (The Cost of a Change).
- Avoidable: re-running the match on every render rather than on every location change. It is cheap, but it is cheap work done thousands of times, and it usually drags a fresh object identity along with it.
From a path to a match
Matching is a short pipeline, and every step in it has a well-known way of going wrong. Walking it deliberately is the fastest way to debug a route that "should" have matched, because the failure is nearly always in a step people do not know exists — usually decoding, or normalisation.
The one non-obvious ordering constraint is that splitting comes before decoding. /tags/vue%2Frouter is one segment whose decoded value contains a slash; decode first and it becomes two segments and matches something else entirely. Servers and CDNs have their own opinions here, which is why an encoded slash is a reliable source of client-and-server disagreement.
- 1Take the pathname
Read
location.pathname— not the search string, not the hash. Those are read after a route has been chosen.fails by Matching against the whole URL, so a query string changes which route wins.
- 2Normalise
Apply the canonical form: trailing slash policy, case policy, locale prefix extraction.
fails by No policy at all, so
/ordersand/orders/behave differently and one of them 404s. - 3Split into segments
Split on
/first, producing the raw segment list.fails by Splitting a decoded path, so an encoded
%2Finside a value becomes a separator. - 4Decode each segment
Percent-decode segments individually, so values containing reserved characters survive.
fails by Decoding malformed input, which throws — an unhandled
URIErrortakes the whole navigation down. - 5Score candidate patterns
Compare segment by segment: static beats dynamic beats wildcard, and a longer exact prefix beats a shorter one.
fails by First-match-wins ordering, where
/orders/:idregistered above/orders/newshadows it. - 6Build the match chain
Collect the winning route and all of its ancestors, each contributing a layout and possibly a data dependency.
fails by Treating the leaf as the only match, so parent layouts remount on every child change.
- 7Extract params
Bind each dynamic segment to a name; hand the result to the view as strings.
fails by Trusting them. Every value here is arbitrary user input (URL Parameters).
- 8Fall through
If nothing matched, render the not-found route deliberately.
fails by Rendering nothing, which looks like a crash and reports like one.
Steps three and four are the ones nobody expects to be ordered. Everything else fails loudly.
Order versus rank
There are two families of matcher and the difference is not stylistic. An ordered matcher walks the table and takes the first pattern that matches, which makes registration order part of the behaviour. A ranked matcher scores every candidate by specificity and takes the best, which makes the table an unordered set.
The practical consequence is about change, not about correctness on day one. In an ordered table, adding a route can break a route you did not touch, and the break is silent — the shadowed route simply stops being reachable. In a ranked table that cannot happen, at the cost of a scoring rule you have to look up when two patterns genuinely tie.
routes = [
{ path: '/orders/:id', view: OrderDetail },
{ path: '/orders/new', view: OrderCreate }, // unreachable
{ path: '/orders/*', view: OrdersFallback },
]
// GET /orders/new -> OrderDetail with { id: "new" }
// fetch("/api/orders/new") -> 404 -> "Order not found"routes = [
{ path: '/orders/:id', view: OrderDetail },
{ path: '/orders/new', view: OrderCreate },
{ path: '/orders/*', view: OrdersFallback },
]
// score per segment: static > dynamic > wildcard
// /orders/new -> [static, static] beats [static, dynamic]
// GET /orders/new -> OrderCreate, regardless of array orderThe arrays are identical. In the ordered matcher the second entry is dead code and nothing tells you, so the bug is discovered by a user trying to create an order. Ranking makes specificity — which is what you meant — the rule, and removes an entire class of change that breaks unrelated code. The cost is that ties are resolved by an algorithm you now have to know.
Routes are a tree, and the match is a path through it
Nesting is where routing stops being a lookup and starts being architecture. A nested table says which parts of the screen persist across which navigations: moving from /orders/1 to /orders/2 should keep the shell and the orders list mounted and change only the detail pane, and that behaviour is expressed by where the routes sit in the tree rather than by any diffing.
This has a direct render-cost consequence. If the match chain shares its first three levels with the previous chain, those three layouts are reused: no unmount, no remount, no re-layout of the sidebar. If your table is flat, every navigation replaces the whole page, and the browser re-does style, layout and paint for content that did not change (The Cost of a Change).
1const routes = [2 {3 path: '/',4 layout: AppShell,5 children: [6 {7 path: 'orders',8 layout: OrdersLayout,9 children: [10 { index: true, view: EmptyState },11 {12 path: ':id',13 view: OrderDetail,14 children: [15 { path: 'items', view: ItemsTable },16 { path: 'history', view: OrderHistory },17 ],18 },19 ],20 },21 { path: '*', view: NotFound },22 ],23 },24]25 26// match("/orders/7/items") does not return one route. It returns a chain:27// [ AppShell, OrdersLayout, OrderDetail, ItemsTable ]28// params: { id: "7" } <- strings, always29//30// Navigating to /orders/8/items reuses AppShell and OrdersLayout untouched.31// Only the last two levels unmount and remount.The thing to notice is what the chain buys you: the sidebar's scroll position, the list's virtualisation state and any open menu in the shell all survive the navigation, because those components were never unmounted. A flat table throws all of it away on every click.
How to build it
Most important first.
- Prefer a router that ranks matches by specificity over one that takes the first match in registration order. Ranking makes the route table an unordered set, which means adding a route cannot change an existing one.
- If your router is order-sensitive, treat the ordering as source code with a comment explaining it, and put static routes above dynamic ones by policy rather than by luck.
- Pick one canonical form for trailing slashes and case, and redirect everything else to it with a replace rather than a push, so back does not bounce (History and Navigation).
- Model nesting after the UI's layout tree, not after the data model. A route exists to say "this shell stays, that part changes"; if a parent route renders nothing, it probably should not be a route.
- Always define a not-found route, and make it a real page with a heading, a search box and a link home. A blank screen is what a missing catch-all looks like.
- Keep the client route table and the server's rewrite rule in the same review. Every new top-level path is a deployment concern as well as a code change (CDN Delivery).
- Split route modules so that matching does not require loading them. The table should know a route exists without knowing what it renders (Lazy Loading).
Keyboard, focus, semantics, announcement
A required field on every lesson in this domain, not a section added when there is room.
- A not-found route is an accessibility surface, not a decoration. It needs a real
h1, focus moved into it and an announcement, exactly like any other navigation — otherwise a screen-reader user who mistypes a URL gets silence (Focus Management). - The match chain determines the heading outline. If every nested layout emits its own
h1, screen-reader heading navigation becomes meaningless; decide which level in the chain owns the page heading (Document Structure and Reading Order). - Landmarks belong to layouts, not to leaf routes. A
navthat is remounted on every match change may be re-announced, and amainthat moves between layouts breaks skip links (Keyboard Operability). - Redirect-on-normalise must not be silent for assistive technology: if
/Orders/becomes/orders, the user should still get one announcement of the destination, not zero because the router treated it as an internal correction.
What can go wrong
- Order-dependent tables where a dynamic route shadows a static sibling. This is the single most common routing bug, and it presents as a feature that "used to work".
- A catch-all that swallows genuine 404s, so every typo renders the dashboard and users report "the link went to the wrong page" rather than "the link is broken".
- Overlapping patterns across nested levels, where a child route and a sibling wildcard both match and the winner changes between router versions.
- A server that does not know about client routes: deep links work when clicked and 404 when refreshed or shared. It is invisible in development, where the dev server rewrites everything.
- The mitigation fails too: an over-eager SPA fallback that rewrites *everything* to the app shell turns a missing image or a broken API path into an HTML document with a 200 status, which breaks error handling much further downstream (Network Failures Only the Client Can See).
- Case-sensitivity mismatches between the client matcher and an object-storage origin, so
/Aboutworks locally and 404s in production (Object Storage in Cloud).
- A navigation and a lazily-imported route module: the match resolves immediately, the component arrives later, and a second navigation in between must not render the first module when it lands (Lazy Loading).
- A redirect emitted by a route and a user-initiated navigation issued at the same moment; without a guard, the redirect can land after the user has already gone somewhere else.
- Matching is not authorization. A route table that omits
/adminon the client does not stop anyone from calling the admin API; it stops them from seeing a link (Authorization-Aware UI). - Wildcard segments capture arbitrary user-controlled text, including
..sequences and encoded separators. If a splat value is ever used to build a path for a request, normalise and allowlist it (Path Traversal). - A catch-all route that renders its own path back to the user is a reflected injection sink — "no page found at
<script>" is the classic form (Cross-Site Scripting). - The SPA fallback rewrite is a security-relevant deployment rule: rewriting unknown paths to the shell means an attacker can produce a 200-status page at any path on your origin, which matters for phishing-style URLs and for anything that trusts a status code (Deploying a Frontend).
- "Routes are regular expressions." Some routers compile to regular expressions, but the model is segment-and-specificity. Reasoning about it as regex is how you end up with a pattern that matches half a segment.
- "Whichever route is listed first wins." True in order-sensitive routers, false in ranked ones, and the difference silently changes behaviour when you migrate between them.
- "The catch-all handles 404s." A catch-all handles unmatched client paths. A URL that never reached your application at all is the server's 404, and the two need separate answers.
- "Nested routes are just nested components." They are also nested data dependencies and nested loading boundaries; the nesting you choose for layout is inherited by both (Route Loading Boundaries).
- "Matching is a performance concern." It almost never is. The cost is in what the match renders and what it fetches.
Measuring it, and what changes in the field
- The most useful measurement is a test, not a panel: assert the resolved route for a list of representative URLs, including the awkward ones — trailing slash, uppercase, encoded separator, unknown path.
- Most routers expose the resolved match chain in devtools or through a hook; reading it is far faster than guessing which pattern won (Debugging State).
- For the refresh-404 class of bug, the Network panel is definitive: the document request for the deep link either returns your shell or it does not (Debugging the Network).
- Bundle analysis tells you whether route modules are being pulled into the entry chunk by the act of registering them (Bundle Analysis).
- With a large route table — hundreds of entries in a file-system-routed application — linear scanning is measurable at startup, and prefix-tree compilation stops being a micro-optimisation.
- On a slow network, an unmatched route is much worse than it looks: the user waits for the whole bundle before being told the page does not exist. Server-side handling of obvious 404s avoids that entirely (Server-Side Rendering).
- Behind a CDN, the fallback rule lives in edge configuration rather than in your application, so client and edge route knowledge drift apart on separate deployment schedules (CDN Delivery).
- In an internationalised application, a locale prefix adds a segment to every route and a whole class of ambiguity — is
/dea locale or a page (Internationalization).
- Ranking is more predictable than ordering, but it hides the rule: when two patterns tie, you need to know the scoring algorithm to predict the winner, and it is rarely written down.
- Deep nesting expresses layouts elegantly and makes data loading harder to reason about, because each level may want its own data and the levels resolve together (Route Loading Boundaries).
- File-system routing removes the route table as a thing to maintain and replaces it with directory names as API — renaming a folder is now a URL change with SEO consequences.
- Strict canonicalisation (one form of every URL) is correct and costs a redirect on some inbound links, which is an extra round trip on exactly the visits that came from outside (Reading a Network Waterfall).
Where this applies
Frontend advice ages badly and fragments across engines. These labels say what each claim is specific to, and where a different browser, device or framework would differ.
- GENERALSegmentation, specificity ordering (static over dynamic over wildcard) and the nested match chain are shared conceptual machinery across client routers, server routers and CDN rewrite rules, because they all answer the same question about the same string.
- FRAMEWORK-SPECIFICThe resolution strategy differs and matters: React Router v6 and Vue Router rank candidates by specificity so registration order is irrelevant, while Express-style middleware chains take the first registered match. File-system routers such as Next.js and SvelteKit derive both the pattern and its rank from directory names, with their own precedence rules for dynamic and catch-all segments.
- PLATFORM-SPECIFICWhether an unmatched path reaches your client router at all is a hosting decision: a static host needs an explicit SPA fallback rewrite, a Node server needs a catch-all handler, and a CDN needs an edge rule. The same application 404s on one and works on another with no code difference.
Where the depth lives
This domain teaches the browser-side mechanism and hands the rest off.
- — Testing & Reliability Engineering — a route table is one of the few frontend artifacts with an exhaustive, cheap test: assert the resolved chain for a fixed list of URLs, including the malformed ones.