|
| 1 | +# Route Patterns |
| 2 | + |
| 3 | +Route patterns are strings that describe the structure of URLs you want to match. |
| 4 | + |
| 5 | +This spec discusses what the user sees when they interact with route patterns. |
| 6 | +It does not discuss the algorithms nor data structures used by the matching engine, which will be discussed elsewhere. |
| 7 | + |
| 8 | +## Goals |
| 9 | + |
| 10 | +- Simple metric for ranking matches |
| 11 | +- Detect unreachable routes |
| 12 | +- Benchmark in the same ballpark as existing solutions |
| 13 | + |
| 14 | +## Non-goals |
| 15 | + |
| 16 | +- Matching URL fragments (`#section`) |
| 17 | +- Matching URL port (`:8080`) |
| 18 | +- Matching URL credentials (`user:pass@`) |
| 19 | +- Caching |
| 20 | +- Request/Response handling |
| 21 | + |
| 22 | +## Quick example |
| 23 | + |
| 24 | +This example is here to jump-start your intuition. |
| 25 | +Don't worry if something is unclear; we'll cover things in excruciating detail in later sections. |
| 26 | + |
| 27 | +```ts |
| 28 | +const matcher = createMatcher([ |
| 29 | + 'products/:id', |
| 30 | + 'products/sku-:sku(/compare/sku-:sku2)', |
| 31 | + 'blog/:year-:month-:day/:slug(.html)', |
| 32 | + '://:tenant.remix.run/admin/users/:userId', |
| 33 | +]); |
| 34 | + |
| 35 | +const url = 'https://remix.run/products/wireless-headphones'; |
| 36 | +const match = matcher.match(url); |
| 37 | + |
| 38 | +console.log(match?.pattern); // 'products/:id' |
| 39 | +console.log(match?.params); // { id: 'wireless-headphones' } |
| 40 | +``` |
| 41 | + |
| 42 | +If you want fine-grained control, you can also get all matches in ranked order: |
| 43 | + |
| 44 | +```ts |
| 45 | +const url = 'https://remix.run/products/sku-electronics-12345'; |
| 46 | +for (const match of matcher.matches(url)) { |
| 47 | + console.log(`${match.pattern} -> ${JSON.stringify(match.params)}`); |
| 48 | +} |
| 49 | +// products/sku-:sku(/compare/sku-:sku2) -> { sku: 'electronics-12345' } |
| 50 | +// products/:id -> { id: 'sku-electronics-12345' } |
| 51 | +``` |
| 52 | + |
| 53 | +## Route pattern parts |
| 54 | + |
| 55 | +Route patterns are composed of 4 parts: protocol, hostname, pathname and search. |
| 56 | +You can use any combination of these to create a route pattern, for example: |
| 57 | + |
| 58 | +```ts |
| 59 | +'/products'; // pathname |
| 60 | +'/search?q'; // pathname + search |
| 61 | +'https://remix.run/store'; // protocol + hostname + pathname |
| 62 | +'://remix.run/store'; // hostname + pathaname |
| 63 | +'file:///usr/bin'; // protocol + pathname |
| 64 | +// ...and so on... |
| 65 | +``` |
| 66 | + |
| 67 | +**Part delimiters:** Route patterns use the first occurrences of `://`, `/`, and `?` as delimiters to split a route pattern into its parts. |
| 68 | +Pathname-only route patterns are the most common, so route patterns are assumed to be pathname-only unless `://` or `?` are present. |
| 69 | +As a result, hostnames must begin with `://` and searches must begin with `?` to distinguish both from pathnames. |
| 70 | + |
| 71 | +**Case Sensitivity:** Protocol and hostname are case-insensitive, while pathname and search are case-sensitive. |
| 72 | + |
| 73 | +**Omitting parts:** For protocol, hostname, and pathname omitting that part means "match anything" for that part. |
| 74 | +However, omitting a pathname means "match the 'empty' pathname" (namely `""` and `"/"`) |
| 75 | + |
| 76 | +```ts |
| 77 | +'://api.example.com/users'; |
| 78 | +// ✓ matches: https://api.example.com/users |
| 79 | +// ✓ matches: http://api.example.com/users |
| 80 | +// ✓ matches: ftp://api.example.com/users |
| 81 | + |
| 82 | +'/api/users'; |
| 83 | +// ✓ matches: https://example.com/api/users |
| 84 | +// ✓ matches: https://staging.api.com/api/users |
| 85 | +// ✓ matches: https://localhost:3000/api/users |
| 86 | + |
| 87 | +'https://api.example.com'; |
| 88 | +// ✓ matches: https://api.example.com |
| 89 | +// ✓ matches: https://api.example.com/ |
| 90 | +// ✗ doesn't match: https://api.example.com/users |
| 91 | +``` |
| 92 | + |
| 93 | +## Pattern modifiers |
| 94 | + |
| 95 | +Each pattern modifier — [param](#params), [glob](#globs), or [optional](#optionals) — applies only in the same part of the URL where it appears. |
| 96 | +As a result: |
| 97 | + |
| 98 | +- Params and globs do not match characters that appear outside of their part of the route pattern |
| 99 | +- Optionals must begin and end within the same part of the route pattern |
| 100 | + |
| 101 | +### Params |
| 102 | + |
| 103 | +| | protocol | hostname | pathname | search | |
| 104 | +| ---------- | -------- | -------- | -------- | ------ | |
| 105 | +| Supported? | ❌ | ✅ | ✅ | ❌ | |
| 106 | + |
| 107 | +Params match dynamic parts of a URL within a segment. |
| 108 | + |
| 109 | +They are written as a `:` optionally followed by a [JavaScript identifier](#javascript-identifier) that acts as its name: |
| 110 | + |
| 111 | +```ts |
| 112 | +'products/:id'; |
| 113 | +// /products/wireless-headphones → { id: 'wireless-headphones' } |
| 114 | +// /products/123 → { id: '123' } |
| 115 | +``` |
| 116 | + |
| 117 | +When a param name is not given, the matched value won't be returned: |
| 118 | + |
| 119 | +```ts |
| 120 | +'products/:-shoes'; |
| 121 | +// /products/tennis-shoes -> {} |
| 122 | +``` |
| 123 | + |
| 124 | +Param names must be unique: |
| 125 | + |
| 126 | +```ts |
| 127 | +// ❌ Bad - duplicate param name |
| 128 | +'users/:id/posts/:id'; |
| 129 | + |
| 130 | +// ✅ Good - unique param names |
| 131 | +'users/:user/posts/:post'; |
| 132 | + |
| 133 | +// ❌ Bad - duplicate param name across hostname and pathname |
| 134 | +'://:region.api.example.com/users/:region'; |
| 135 | +``` |
| 136 | + |
| 137 | +Params can be mixed with static text and even other params: |
| 138 | + |
| 139 | +```ts |
| 140 | +'users/@:id'; |
| 141 | +// /users/@sarah → { id: 'sarah' } |
| 142 | + |
| 143 | +'downloads/:filename.pdf'; |
| 144 | +// /downloads/report.pdf → { filename: 'report' } |
| 145 | + |
| 146 | +'api/v:major.:minor-:channel'; |
| 147 | +// /api/v2.1-beta → { major: '2', minor: '1', channel: 'beta' } |
| 148 | + |
| 149 | +'://us-:region.:env.api.example.com'; |
| 150 | +// us-east.staging.api.example.com → { region: 'east', env: 'staging' } |
| 151 | +``` |
| 152 | + |
| 153 | +### Globs |
| 154 | + |
| 155 | +| | protocol | hostname | pathname | search | |
| 156 | +| ---------- | -------- | -------- | -------- | ------ | |
| 157 | +| Supported? | ❌ | ✅ | ✅ | ❌ | |
| 158 | + |
| 159 | +Globs match dynamic parts of a URL, but — unlike [params](#params) — they are not limited to a single segment. |
| 160 | + |
| 161 | +They are written as a `*` optionally followed by a [JavaScript identifier](#javascript-identifier) that acts as its name: |
| 162 | + |
| 163 | +```ts |
| 164 | +// todo |
| 165 | +``` |
| 166 | + |
| 167 | +When a glob name is not given, the matched value won't be returned: |
| 168 | + |
| 169 | +```ts |
| 170 | +// todo |
| 171 | +``` |
| 172 | + |
| 173 | +Globs share a namespace with params: |
| 174 | + |
| 175 | +```ts |
| 176 | +// todo |
| 177 | +``` |
| 178 | + |
| 179 | +### Optionals |
| 180 | + |
| 181 | +| | protocol | hostname | pathname | search | |
| 182 | +| ---------- | -------- | -------- | -------- | ------ | |
| 183 | +| Supported? | ✅ | ✅ | ✅ | ❌ | |
| 184 | + |
| 185 | +You can mark any part of a pattern as optional by enclosing it in parentheses `()`. |
| 186 | + |
| 187 | +```ts |
| 188 | +'products/:id(/edit)'; |
| 189 | +// /products/winter-jacket → { id: 'winter-jacket' } |
| 190 | +// /products/winter-jacket/edit → { id: 'winter-jacket' } |
| 191 | + |
| 192 | +'http(s)://api.example.com'; |
| 193 | +// http://api.example.com → {} |
| 194 | +// https://api.example.com → {} |
| 195 | +``` |
| 196 | + |
| 197 | +Optionals can span any characters and contain static text, params, or wildcards: |
| 198 | + |
| 199 | +```ts |
| 200 | +'download/:filename(.pdf)'; |
| 201 | +// /download/report → { filename: 'report' } |
| 202 | +// /download/report.pdf → { filename: 'report' } |
| 203 | + |
| 204 | +'api(/v:version)/users'; |
| 205 | +// /api/users → {} |
| 206 | +// /api/v2/users → { version: '2' } |
| 207 | + |
| 208 | +'users/:id(/settings/:section)(/edit)'; |
| 209 | +// /users/sarah → { id: 'sarah' } |
| 210 | +// /users/sarah/settings/profile → { id: 'sarah', section: 'profile' } |
| 211 | +// /users/sarah/settings/profile/edit → { id: 'sarah', section: 'profile' } |
| 212 | + |
| 213 | +'users/:userId(/files/:)'; |
| 214 | +// /users/sarah → { userId: 'sarah' } |
| 215 | +// /users/sarah/files/document.pdf → { userId: 'sarah' } |
| 216 | + |
| 217 | +'users/:userId(/docs/*)'; |
| 218 | +// /users/sarah → { userId: 'sarah' } |
| 219 | +// /users/sarah/docs/projects/readme.md → { userId: 'sarah' } |
| 220 | + |
| 221 | +'users/:userId(/files/*path)'; |
| 222 | +// /users/sarah → { userId: 'sarah' } |
| 223 | +// /users/sarah/files/projects/docs/readme.md → { userId: 'sarah', path: 'projects/docs/readme.md' } |
| 224 | + |
| 225 | +'://(www.)shop.example.com'; |
| 226 | +// shop.example.com → {} |
| 227 | +// www.shop.example.com → {} |
| 228 | + |
| 229 | +'://(:.)api.example.com(/v:)'; |
| 230 | +// api.example.com → {} |
| 231 | +// cdn.api.example.com/v2 → {} |
| 232 | +``` |
| 233 | + |
| 234 | +Optionals cannot be nested: |
| 235 | + |
| 236 | +```ts |
| 237 | +// ❌ Bad - nested optionals not allowed |
| 238 | +'users/:id(/settings(/advanced))'; |
| 239 | + |
| 240 | +// ✅ Good - use multiple separate patterns |
| 241 | +'users/:id'; |
| 242 | +'users/:id/settings(/advanced)'; |
| 243 | +``` |
| 244 | + |
| 245 | +Optionals cannot span across multiple parts of a route pattern: |
| 246 | + |
| 247 | +```ts |
| 248 | +// ❌ Bad - optional starts in protocol, ends in hostname |
| 249 | +'http(s://api).example.com'; |
| 250 | + |
| 251 | +// ❌ Bad - optional starts in hostname, ends in pathname |
| 252 | +'://(api.example.com/users)/settings'; |
| 253 | + |
| 254 | +// ❌ Bad - optional spans protocol and pathname |
| 255 | +'http(s://example.com/api)'; |
| 256 | + |
| 257 | +// ✅ Good - separate optionals for each part |
| 258 | +'http(s)://api.example.com(/settings)'; |
| 259 | + |
| 260 | +// ✅ Good - optional contained within hostname |
| 261 | +'://(www.)example.com'; |
| 262 | + |
| 263 | +// ✅ Good - optional contained within pathname |
| 264 | +'://example.com(/api/v2)'; |
| 265 | +``` |
| 266 | + |
| 267 | +### Escaping special characters |
| 268 | + |
| 269 | +Use backslash `\` to escape special characters in the patterns language: `:`, `*`, `(` and `)`. |
| 270 | + |
| 271 | +**Note:** In JavaScript code, you'll need `\\` since backslash itself needs to be escaped in a string: |
| 272 | + |
| 273 | +```ts |
| 274 | +'/api\\:v2/users'; |
| 275 | +// ✅ Matches: /api:v2/users (literal colon) |
| 276 | +// ❌ Does NOT match: /apiv2/users (param :v2 would consume "v2") |
| 277 | + |
| 278 | +'/files\\*.backup'; |
| 279 | +// ✅ Matches: /files*.backup (literal asterisk) |
| 280 | +// ❌ Does NOT match: /files/document.backup (wildcard * would match "document") |
| 281 | + |
| 282 | +'/docs\\*\\*/readme.md'; |
| 283 | +// ✅ Matches: /docs**/readme.md (literal asterisks) |
| 284 | +// ❌ Does NOT match: /docs/api/v1/readme.md (** would match "api/v1") |
| 285 | + |
| 286 | +'/wiki/Mercury\\(planet\\)'; |
| 287 | +// ✅ Matches: /wiki/Mercury(planet) (literal parentheses for disambiguation) |
| 288 | +// ❌ Does NOT match: /wiki/Mercury (optionals would make "(planet)" optional) |
| 289 | + |
| 290 | +'://api\\*.example.com'; |
| 291 | +// ✅ Matches: ://api*.example.com (literal asterisk in hostname) |
| 292 | +// ❌ Does NOT match: ://api-cdn.example.com (wildcard * would match "-cdn") |
| 293 | + |
| 294 | +'/search\\:query\\(\\*\\*\\)'; |
| 295 | +// ✅ Matches: /search:query(**) (all literal characters) |
| 296 | +``` |
| 297 | + |
| 298 | +## Definitions |
| 299 | + |
| 300 | +### JavaScript identifier |
| 301 | + |
| 302 | +For the purposes of this spec, JavaScript identifiers match this regular expression: [`/[a-zA-Z_$0-9][a-zA-Z_$0-9]*/`](https://regexr.com/8fcn3) |
0 commit comments