Skip to content

Tutorial: Working with URL Patterns ​

In this tutorial, you will learn how to use the route-pattern package to match URLs against patterns, extract parameters, generate URLs, and compare pattern specificity. These are the building blocks that power Remix's router under the hood.

Prerequisites ​

You should be comfortable with JavaScript. No server programming experience is needed.

Step 1: Parse and Match a Pattern ​

A RoutePattern is created from a pattern string. Once created, you can match URLs against it.

ts
import { RoutePattern } from 'remix/route-pattern'

// Create a pattern with a dynamic segment
let pattern = new RoutePattern('/products/:id')

// Match a URL
let match = pattern.match(new URL('http://localhost/products/42'))

if (match) {
  console.log(match.params) // { id: "42" }
}

// Non-matching URLs return null
let noMatch = pattern.match(new URL('http://localhost/about'))
console.log(noMatch) // null

What is a Dynamic Segment?

A dynamic segment is a part of the URL pattern that starts with : (like :id). It acts as a placeholder that matches any single path segment. When a URL matches, the actual value is captured in params.

Step 2: Use Different Segment Types ​

Patterns support four kinds of segments. Let's try each one:

ts
import { RoutePattern } from 'remix/route-pattern'

// Static -- matches exactly
let about = new RoutePattern('/about')
about.test(new URL('http://localhost/about'))   // true
about.test(new URL('http://localhost/contact')) // false

// Dynamic -- matches one segment
let user = new RoutePattern('/users/:id')
user.match(new URL('http://localhost/users/alice'))!.params
// { id: "alice" }

// Wildcard -- matches everything after the prefix
let files = new RoutePattern('/files/*path')
files.match(new URL('http://localhost/files/docs/readme.md'))!.params
// { path: "docs/readme.md" }

// Optional -- the segment may or may not be present
let lang = new RoutePattern('/docs/(:lang)')
lang.match(new URL('http://localhost/docs'))!.params
// { lang: undefined }
lang.match(new URL('http://localhost/docs/en'))!.params
// { lang: "en" }

Step 3: Generate URLs with href() ​

Instead of building URL strings by hand, let the pattern do it for you:

ts
import { RoutePattern } from 'remix/route-pattern'

let pattern = new RoutePattern('/users/:userId/posts/:postId')

// Generate a URL by providing the required params
let url = pattern.href({ userId: '5', postId: '99' })
console.log(url) // "/users/5/posts/99"

// Static patterns need no params
let home = new RoutePattern('/')
home.href() // "/"

// You can also add query parameters (second argument)
let search = new RoutePattern('/search')
search.href({}, { q: 'remix', page: '2' })
// "/search?q=remix&page=2"

If you forget a required parameter, href() throws a HrefError at runtime. With TypeScript, you get a compile-time error instead.

Step 4: Join Patterns Together ​

The join() method combines two patterns into one. This is useful when you have a base path and want to append more specific patterns:

ts
import { RoutePattern } from 'remix/route-pattern'

let base = new RoutePattern('/api/v1')
let endpoint = new RoutePattern('/users/:id')

let joined = base.join(endpoint)
console.log(joined.source) // "/api/v1/users/:id"

joined.href({ id: '42' }) // "/api/v1/users/42"

Step 5: Compare Specificity ​

When multiple patterns match the same URL, you need to decide which one should win. Specificity is the set of rules that determines priority.

The rules are:

  1. Static segments beat dynamic segments (/users/new beats /users/:id)
  2. Dynamic segments beat wildcards (/files/:name beats /files/*path)
  3. Longer static matches beat shorter ones
  4. More search constraints beat fewer
ts
import { RoutePattern } from 'remix/route-pattern'
import {
  compare,
  ascending,
  greaterThan,
} from 'remix/route-pattern/specificity'

let url = new URL('http://localhost/users/new')

let staticPattern = new RoutePattern('/users/new')
let dynamicPattern = new RoutePattern('/users/:id')
let wildcardPattern = new RoutePattern('/users/*rest')

let staticMatch = staticPattern.match(url)!
let dynamicMatch = dynamicPattern.match(url)!
let wildcardMatch = wildcardPattern.match(url)!

// Compare two matches directly
greaterThan(staticMatch, dynamicMatch)   // true (static wins)
greaterThan(dynamicMatch, wildcardMatch) // true (dynamic wins)

// Sort an array of matches (least specific first)
let sorted = [wildcardMatch, staticMatch, dynamicMatch].sort(ascending)
console.log(sorted.map((m) => m.pattern.source))
// ["/users/*rest", "/users/:id", "/users/new"]

Step 6: Use Matchers for Multiple Patterns ​

When you have many patterns, a matcher stores them all and finds the best match efficiently. The route-pattern package provides two implementations.

ArrayMatcher ​

Tests patterns one by one. Simple and works well for small numbers of routes:

ts
import { ArrayMatcher, RoutePattern } from 'remix/route-pattern'

let matcher = new ArrayMatcher()

// Add patterns with associated data (any value you want)
matcher.add(new RoutePattern('/'), { name: 'home' })
matcher.add(new RoutePattern('/users'), { name: 'userList' })
matcher.add(new RoutePattern('/users/:id'), { name: 'userShow' })
matcher.add(new RoutePattern('/users/new'), { name: 'userNew' })

// Find the best match
let match = matcher.match(new URL('http://localhost/users/new'))
console.log(match.data)   // { name: "userNew" } (static wins)
console.log(match.params) // {}

// Find ALL matches, sorted by specificity
let all = matcher.matchAll(new URL('http://localhost/users/new'))
console.log(all.map((m) => m.data.name))
// ["userNew", "userShow"] -- most specific first

TrieMatcher ​

Uses a trie (a tree-shaped data structure) for faster lookups. The API is identical to ArrayMatcher:

ts
import { TrieMatcher, RoutePattern } from 'remix/route-pattern'

let matcher = new TrieMatcher()

// Same API as ArrayMatcher
matcher.add(new RoutePattern('/users'), { handler: listUsers })
matcher.add(new RoutePattern('/users/:id'), { handler: showUser })

let match = matcher.match(new URL('http://localhost/users/42'))
console.log(match.params) // { id: "42" }

Which Matcher Should I Use?

For most applications, ArrayMatcher (the default in Remix's router) is perfectly fine. Switch to TrieMatcher if you have hundreds or thousands of routes and notice performance issues during matching.

Step 7: Handle Parse Errors ​

If you pass an invalid pattern string, the constructor throws a ParseError:

ts
import { RoutePattern, ParseError } from 'remix/route-pattern'

try {
  new RoutePattern('/users/:/oops')
} catch (error) {
  if (error instanceof ParseError) {
    console.error('Invalid pattern:', error.message)
  }
}

This is useful when patterns come from user input or configuration files that might contain mistakes.

What You Learned ​

  • RoutePattern parses pattern strings and matches them against URLs.
  • Dynamic segments (:param), wildcards (*name), and optional segments ((:param)) capture different kinds of values.
  • href() generates URLs from patterns and parameters.
  • join() combines patterns for building prefixed routes.
  • Specificity determines which pattern wins when multiple patterns match.
  • ArrayMatcher and TrieMatcher store multiple patterns and find the best match.

Next Steps ​

Released under the MIT License.