remix/route-pattern
Type-safe URL matching and href generation for JavaScript. route-pattern supports path variables, wildcards, optionals, search constraints, and full-URL patterns with predictable ranking.
Features
- Type-safe - Infer params from patterns for compile-time correctness
- Expressive - Variables, wildcards, optionals, and search constraints
- Full URL support - Match protocol, hostname, port, pathname, and search
- Simple & deterministic ranking - Predictable left-to-right priority for static, variable, and wildcard patterns
- Fast - Indexed, bounded-state matching without variant expansion or regex backtracking
- Modular - Import only the features you need to for smaller bundles
- Runtime agnostic - Works across Node.js, Bun, Deno, Cloudflare Workers, and browsers
Installation
npm i remixQuick example
import { createMultiMatcher } from 'remix/route-pattern/match'
let matcher = createMultiMatcher<{ name: string }>()
matcher.add('blog/:slug', { name: 'blog-post' })
matcher.add('api(/v:version)/*path', { name: 'api' })
matcher.add('http(s)://:region.cdn.com/assets/*file.:ext', { name: 'assets' })
let match = matcher.match('https://example.com/blog/v3')
match?.pattern.toString()
// /blog/:slug
match?.params
// { slug: 'v3' }
match?.data
// { name: 'blog-post' }
import { createHref } from 'remix/route-pattern/href'
createHref('blog/:slug', { slug: 'v3' })
// '/blog/v3'
createHref('api(/v:version)/*path', { version: '2', path: 'users/profile' })
// '/api/v2/users/profile'
createHref('http(s)://:region.cdn.com/assets/*file.:ext', {
region: 'us-west',
file: 'images/logo',
ext: 'png',
})
// 'https://us-west.cdn.com/assets/images/logo.png'API at a glance
| Import | Description |
|---|---|
remix/route-pattern |
Parse and stringify patterns. |
remix/route-pattern/href |
Generate hrefs for patterns with type safe params. |
remix/route-pattern/match |
Match against one pattern with type inference for params, or match against many patterns with deterministic ranking and attached data. |
remix/route-pattern/join |
Combine two patterns into one. Override protocol, hostname, port. Join pathnames. Merge search constraints. |
remix/route-pattern/specificity |
Rank matches by specificity. |
For in-depth reference, visit the route-pattern API docs
Examples in this README use remix/route-pattern/* imports. The same APIs are also available from the direct package entrypoints: @remix-run/route-pattern, @remix-run/route-pattern/href, @remix-run/route-pattern/match, @remix-run/route-pattern/join, and @remix-run/route-pattern/specificity.
Pattern syntax
Protocol
Protocol must be http, https, or http(s):
'https://example.com' // matches https://example.com
'http(s)://example.com' // matches http://example.com, https://example.comHostname & pathname
Variables capture dynamic segments using :name:
'users/:id' // matches /users/123
'blog/:date/:slug' // matches /blog/2024-01-15/hello
'files/:name.:ext' // matches /files/readme.mdPathname variables possessively capture the largest non-empty run up to / or .. Hyphens are data, so UUIDs and slugs remain intact. A variable may have static text before it, but every path through following optionals must reach /, ., a wildcard, or the end of the hostname or pathname. Capture an inseparable value such as a date with one variable instead of :year-:month-:day.
Raw / and . are structural delimiters. Their percent-encoded forms remain data and are decoded in the resulting param. Static pattern text may use either decoded text or percent encoding, so /café and /caf%C3%A9 match the same pathname text.
Wildcards match multi-segment paths using *name:
'files/*path' // matches /files/images/logo.png
'node_modules/*package/dist/index.js' // matches /node_modules/@remix-run/router/dist/index.js
'files/*' // matches any path under /files, but doesn't capture the wildcard valuePatterns may contain any number of wildcards when static text or a delimiter separates them. Adjacent wildcards such as *left*right are rejected because their capture boundary is ambiguous.
Optionals make parts optional using ():
'api(/v:version)/users' // matches /api/users, /api/v2/users
'blog/:slug(.html)' // matches /blog/hello, /blog/hello.html
'docs(/guides/:category)' // matches /docs, /docs/guides/routing
'api(/v:major(.:minor))' // matches /api, /api/v2, /api/v2.1Optionals compile as state branches rather than concrete variants, so independent and nested optionals do not cause exponential matcher construction. Empty optionals and adjacent optional branches that give the same URL different capture schemas are rejected.
While variables, wildcards, and optionals are most prevalent in pathnames, you can also use them in hostnames:
':tenant.example.com/dashboard' // matches acme.example.com/dashboard
'(www.)example.com/blog/:slug(.html)' // matches example.com/blog/hello, www.example.com/blog/hello.html
'*.example.com/files/*path' // matches cdn.example.com/files/images/logo.png
'(:locale.)example.com/docs(/:section)' // matches en.example.com/docs, en.example.com/docs/guidesCapture names may repeat. params uses the last participating capture in pattern order, while paramsMeta retains every participating capture:
let matcher = createMatcher('/:id/:id')
let match = matcher.match('https://example.com/first/second')
match?.params
// { id: 'second' }
match?.paramsMeta.pathname.map(({ name, value }) => ({ name, value }))
// [{ name: 'id', value: 'first' }, { name: 'id', value: 'second' }]Escape characters with \:
'time/12\\:30' // matches /time/12:30
'calculator/2\\*3' // matches /calculator/2*3
'wiki/Mercury_\\(planet\\)' // matches /wiki/Mercury_(planet)
'wiki/AC\\/DC' // matches /wiki/AC%2FDCSearch
Search constraints narrow matches using ?key or ?key=value:
'search?q' // key must be present
'search?q=routing' // requires ?q=routing exactlyMatch URLs
Match against a single pattern
Use createMatcher when you have one pattern and want params inferred from that exact pattern.
import { createMatcher } from 'remix/route-pattern/match'
const url: string | URL = /* ... */
let blogMatcher = createMatcher('blog/:slug')
blogMatcher.match(url)?.params
// Type safe params ^? { slug: string } | undefined
let docsMatcher = createMatcher('://(:tenant.)host.com/docs/*path.:ext')
docsMatcher.match(url)?.params
// Type safe params ^? { tenant: string | undefined, path: string, ext: string } | undefinedMatchers accept absolute URL strings or URL objects. To match a relative URL reference, pass an absolute baseURL; the input is resolved with the same semantics as new URL(input, baseURL), and the resolved URL is returned on the match.
let match = blogMatcher.match('../blog/v3', {
baseURL: 'https://example.com/admin/settings',
})
match?.params
// { slug: 'v3' }
match?.url.href
// 'https://example.com/blog/v3'This works for root-relative, path-relative, query-relative, and network-path references. Without baseURL, string inputs must still be absolute.
Match against multiple patterns
Use createMultiMatcher when you need to match many patterns and attach your own data to each match.
import { createMultiMatcher } from 'remix/route-pattern/match'
let matcher = createMultiMatcher<string>()
// Any data type you want! 👆
matcher.add('/', 'home')
matcher.add('blog/:slug', 'blog-post')
matcher.add('api(/v:version)/*path', 'api')
matcher.match('https://example.com/blog/v3')
// { params: { slug: 'v3' }, data: 'blog-post' }
matcher.match('https://example.com/api/v2/users/profile')
// { params: { version: '2', path: 'users/profile' }, data: 'api' }The matched pattern is only known at runtime, so matched params are not inferred when matching with createMultiMatcher.
Each match returns:
url: theURLobject that was matchedpattern: the matchedRoutePatterndata: the data attached withmatcher.add(pattern, data)params: captured param valuesparamsMeta: hostname and pathname param metadata
paramsMeta.hostname and paramsMeta.pathname are arrays of { type, name, value, begin, end } entries. The offsets are measured after URL normalization. A pattern with no hostname matches any hostname, represented in paramsMeta.hostname as an unnamed wildcard entry.
Set ignoreCase: true to make pathname matching case-insensitive. Hostname matching is always case-insensitive, and search constraints are always case-sensitive.
let matcher = createMatcher('/Docs/:slug', { ignoreCase: true })
matcher.match('https://example.com/docs/Intro')?.params
// { slug: 'Intro' }Matchers limit individual pattern size, total matcher size, and the work performed by one match. Pattern and matcher sizes are measured in UTF-8 bytes. Direct package consumers may lower or raise individual limits. Exceeding one throws MatcherResourceError with structured details instead of silently abandoning matching:
let matcher = createMultiMatcher({
limits: { maxPatternSize: 4096, maxMatchWork: 100_000 },
})Ranking matches by specificity
When multiple patterns match the same URL, route-pattern chooses the most specific match deterministically. Matches are ranked left-to-right, character-by-character:
- Explicit protocol and port constraints are more specific than omitted constraints.
- Static hostnames are more specific than dynamic hostnames, which are more specific than omitted hostnames.
- Static characters are more specific than variables.
- Variables are more specific than wildcards.
- Earliest difference decides the winner.
This is the same ranking used by createMultiMatcher.
For advanced use cases, /specificity provides comparison utilities: lessThan, greaterThan, equal, descending, ascending, compare. lessThan(a, b) returns true when match a is less specific than match b. For example:
import { createMultiMatcher } from 'remix/route-pattern/match'
import { descending } from 'remix/route-pattern/specificity'
let matcher = createMultiMatcher()
matcher.add('files/*path', null)
matcher.add('files/:name', null)
matcher.add('files/readme', null)
let matches = matcher.matchAll('https://example.com/files/readme')
matches.sort(descending).map((match) => match.pattern.toString())
// ['/files/readme', '/files/:name', '/files/*path']Generate hrefs
createHref turns a pattern and params into a URL string. Required variables and wildcards must be provided, while params inside optional groups may be omitted.
import { createHref } from 'remix/route-pattern/href'
createHref('blog/:slug', { slug: 'v3' })
// '/blog/v3'
createHref('api(/v:version)/*path', { path: 'users/profile' })
// '/api/users/profile'
createHref('api(/v:version)/*path', { version: '2', path: 'users/profile' })
// '/api/v2/users/profile'
createHref('http(s)://:region.cdn.com/assets/*file.:ext', {
region: 'us-west',
file: 'images/logo',
ext: 'png',
})
// 'https://us-west.cdn.com/assets/images/logo.png'
createHref(
'blog/:slug?ref=docs',
{ slug: 'v3' },
{
searchParams: { utm_source: 'newsletter' },
},
)
// '/blog/v3?utm_source=newsletter&ref=docs'
createHref('users/:id', { id: 'a.b' })
// '/users/a%2Eb' (the encoded dot remains variable data when matched)Pass baseURL to generate a path-relative reference to a same-origin route. Patterns with a different origin remain absolute.
let baseURL = new URL('https://example.com/admin/settings')
createHref('users/:id', { id: '123' }, { baseURL })
// '../users/123'
createHref('https://cdn.example.com/assets/*path', { path: 'logo.svg' }, { baseURL })
// 'https://cdn.example.com/assets/logo.svg'The searchParams option accepts a plain object or URLSearchParams. Use URLSearchParams when duplicate keys or their order matter:
let searchParams = new URLSearchParams([
['tag', 'featured'],
['tag', 'popular'],
])
createHref('search', undefined, { searchParams })
// '/search?tag=featured&tag=popular'createHref() throws CreateHrefError when it cannot safely generate an href. The error exposes stable structured details on error.details; the string message is for humans.
Common failures include missing required params, nameless wildcards, invalid hostname params, empty pathname variables, and origin patterns that specify a protocol or port without a concrete hostname.
Note: optional groups without params are included in the generated href:
createHref('todos(/new)')
// '/todos/new'
createHref('products(.json)')
// '/products.json'Parse & stringify patterns
You can explicitly parse and stringify patterns. Create a RoutePattern with RoutePattern.parse and use the methods and helpers below instead of reading parsed token internals.
import { getRoutePatternCaptures, RoutePattern } from 'remix/route-pattern'
let pattern = RoutePattern.parse('://:tenant.example.com/blog/:slug(/*path)')
// ^? RoutePattern
pattern.toString()
// '://:tenant.example.com/blog/:slug(/*path)'
pattern.toJSON()
// { hostname: ':tenant.example.com', pathname: 'blog/:slug(/*path)', ... }
getRoutePatternCaptures(pattern)
// [
// { part: 'hostname', type: ':', name: 'tenant', optional: false },
// { part: 'pathname', type: ':', name: 'slug', optional: false },
// { part: 'pathname', type: '*', name: 'path', optional: true },
// ]All APIs that take a pattern arg accept string or a parsed RoutePattern.
TIP: For high-performance scenarios, you can parse patterns ahead of time to avoid reparsing them on every call.
RoutePattern.toJSON() returns a RoutePatternJSON object with serialized protocol, hostname, port, pathname, and search fields. RoutePattern.parse() throws ParseError for malformed sources; the error exposes stable type, source, and index fields.
The public support types are:
RoutePatternCapturefromremix/route-patternRoutePatternJSONfromremix/route-patternCreateHrefErrorDetailsfromremix/route-pattern/hrefCreateHrefOptionsandCreateHrefSearchParamsfromremix/route-pattern/hrefMatchParamMetafromremix/route-pattern/matchMatchOptionsfromremix/route-pattern/matchMatcherLimitsfromremix/route-pattern/matchMatcherResourceErrorandMatcherResourceErrorDetailsfromremix/route-pattern/match
Literal patterns are validated and infer named params until the type-level parser reaches its 64-step complexity budget. Larger runtime-valid patterns remain accepted and fall back to safe general pattern types instead of risking a TypeScript excessive-instantiation error.
Combine patterns
joinPatterns builds a new pattern from a base pattern.
import { joinPatterns } from 'remix/route-pattern/join'
let user = joinPatterns('users', ':id')
user.toString()
// '/users/:id'
let apiUser = joinPatterns('api(/v:version)', '://remix.run/users/:id')
apiUser.toString()
// '://remix.run/api(/v:version)/users/:id'- Protocol: if second pattern has a protocol, overrides base pattern
- Hostname: if second pattern has a hostname, overrides base pattern
- Port: if second pattern has a port, overrides base pattern
- Pathname: concatenates pathnames, adding a
/in between as necessary - Search constraints: merges search constraints by key
Benchmarks
Benchmarks live in bench/.
Related Work
License
See LICENSE