JavaScript finally got a native routing primitive that works the same way in both the browser and Node.js 24. The URLPattern API can now act as a framework-free router, handling route matching with named groups, strict validation, and consistent semantics everywhere.
In Node 24, URLPattern is available globally (no imports). You can define patterns once and reuse them in Service Workers or HTTP servers, all without external dependencies.
1. The Concept
URLPattern matches URLs by parts — protocol, hostname, port, pathname, search, and hash.
It supports named groups, case sensitivity, and a clean pattern syntax.
Examples:
// Named parameter with validation
new URLPattern({ pathname: '/users/:id([0-9]+)' });
// Optional subdomain
new URLPattern({ hostname: '{:sub.}?api.example.com' });
// Relative path resolved against a base
new URLPattern('../admin/*', 'https://example.com/app/');Browser support is strong (Chrome, Firefox, Safari, Edge), and in Node 24 it’s built-in.
2. A Mini Router with URLPattern
Let’s build a tiny, type-safe router that works in Node and Service Workers — no frameworks, no globals, no dependencies.
// mini-router.js
export class PathRule {
constructor({ method = 'GET', pattern, base, opts, handler, cast = {} }) {
if (!pattern) throw new TypeError('Missing URL pattern');
this.method = method.toUpperCase();
this.pattern =
typeof pattern === 'string'
? new URLPattern(pattern, base, opts)
: new URLPattern(pattern, opts);
this.handler = handler;
this.cast = cast; // { key: fn(string) => any }
}
match(url, base) {
if (!this.pattern.test(url, base)) return null;
const result = this.pattern.exec(url, base);
const params = Object.assign(
{},
result.protocol?.groups,
result.hostname?.groups,
result.port?.groups,
result.pathname?.groups,
result.search?.groups,
result.hash?.groups
);
for (const [k, fn] of Object.entries(this.cast)) {
if (params[k] != null) {
const val = fn(params[k]);
if (val === undefined) return null;
params[k] = val;
}
}
return { params, result };
}
}
export class SmartRouter {
constructor({ base } = {}) {
this.routes = [];
this.base = base;
}
register(ruleDef) {
const rule = new PathRule(ruleDef);
this.routes.push(rule);
return this;
}
find({ url, method = 'GET' }) {
const href = typeof url === 'string' ? url : url.href;
const m = method.toUpperCase();
for (const route of this.routes) {
if (route.method !== m) continue;
const match = route.match(href);
if (match) return { route, ...match };
}
return null;
}
}3. Declaring Routes
Let’s define a small router with type-safe parameters.
// routes.js
import { SmartRouter } from './mini-router.js';
export function initRouter() {
const r = new SmartRouter();
// GET /health
r.register({
method: 'GET',
pattern: { pathname: '/health' },
handler: async () => new Response('ok', { status: 200 }),
});
// GET /users/:id
r.register({
method: 'GET',
pattern: { pathname: '/users/:id([1-9][0-9]*)' },
cast: {
id: s => {
const n = Number(s);
return Number.isSafeInteger(n) ? n : undefined;
},
},
handler: async ({ params }) => toJSON({ id: params.id }),
});
// GET /search?q&limit
r.register({
method: 'GET',
pattern: { pathname: '/search', search: '?q=:q&limit=:limit([0-9]{1,2})' },
cast: {
limit: s => {
const n = Number(s);
return n >= 1 && n <= 50 ? n : 10;
},
},
handler: async ({ url, params }) => {
const q = params.q ?? url.searchParams.get('q') ?? '';
const limit = params.limit ?? 10;
return toJSON({ q, limit });
},
});
return r;
}
function toJSON(obj, init = {}) {
return new Response(JSON.stringify(obj), {
headers: { 'content-type': 'application/json; charset=utf-8' },
...init,
});
}4. Using It in a Service Worker
// worker.js
import { initRouter } from './routes.js';
const router = initRouter();
self.addEventListener('fetch', e => {
const req = e.request;
const url = new URL(req.url);
if (url.origin !== self.location.origin) return;
const found = router.find({ url, method: req.method });
if (!found) return;
e.respondWith(processRequest(found, req, url));
});
async function processRequest(found, req, url) {
try {
const ctx = { request: req, url, params: found.params, match: found.result };
const res = await found.route.handler(ctx);
return sanitizeHeaders(res);
} catch {
return new Response('internal error', { status: 500 });
}
}
function sanitizeHeaders(res) {
const h = new Headers(res.headers);
if (!h.has('cache-control')) h.set('cache-control', 'no-store');
return new Response(res.body, { status: res.status, headers: h });
}5. Using It in Node.js 24
// server.js
import http from 'node:http';
import { initRouter } from './routes.js';
const router = initRouter();
const BASE = process.env.BASE_ORIGIN || 'http://localhost';
const server = http.createServer(async (req, res) => {
const url = new URL(req.url, BASE);
const found = router.find({ url, method: req.method });
if (!found) {
res.writeHead(404, { 'content-type': 'text/plain; charset=utf-8' });
res.end('not found');
return;
}
try {
const ctx = { request: req, url, params: found.params, match: found.result };
const webRes = await found.route.handler(ctx);
await respondNode(res, webRes);
} catch {
res.writeHead(500, { 'content-type': 'text/plain; charset=utf-8' });
res.end('internal error');
}
});
server.listen(3000, () => console.log('Server on http://localhost:3000'));
async function respondNode(res, webResponse) {
const buffer = Buffer.from(await webResponse.arrayBuffer());
const headers = Object.fromEntries(webResponse.headers.entries());
res.writeHead(webResponse.status, headers);
res.end(buffer);
}6. Performance and Security Notes
- Precompile once: reuse
URLPatterninstances instead of creating them per request. - Avoid unsafe regex input: do not accept user-supplied groups.
- Enable
ignoreCaseonly where appropriate. - In Node, always build absolute URLs — relative paths are invalid without a base origin.
- In Service Workers, match only your own origin.
URLPattern may not beat optimized routers like find-my-way in raw performance, but it’s ideal for shared client-server logic and unified validation.
7. Example: Matching Hosts and Protocols
const proto = new URLPattern({ protocol: 'http{s}?' });
const host = new URLPattern({ hostname: '{:sub.}?example.com' });
const assets = new URLPattern({ pathname: '/static/*' });
const user = new URLPattern({ pathname: '/users/:id([1-9][0-9]*)' });
const search = new URLPattern({
pathname: '/search',
search: '?q=:q&limit=:limit([0-9]{1,2})',
});8. Benchmark: URLPattern vs RegExp
// bench.js
const N = Number(process.env.N || 1_000_000);
const samples = [
'http://localhost/users/1?active=1',
'http://localhost/users/9999?active=1',
'http://localhost/users/x?active=1',
'http://localhost/posts/1?active=1',
];
const pattern = new URLPattern({
pathname: '/users/:id([1-9][0-9]*)',
search: '?active=1',
});
const regex = /^\/users\/(?<id>[1-9][0-9]*)$/;
const run = (name, fn) => {
const start = performance.now();
let ok = 0;
for (let i = 0; i < N; i++) {
const u = new URL(samples[i % samples.length]);
if (fn(u)) ok++;
}
console.log(`${name}: ${(performance.now() - start).toFixed(1)} ms, ok=${ok}`);
};
run('URLPattern', u => pattern.test(u));
run('RegExp', u => u.search === '?active=1' && regex.test(u.pathname));9. Unified Handler Contract
/*
Context {
request: Request | http.IncomingMessage,
url: URL,
params: Record<string, string | number>,
match: URLPatternResult
}
Handler: (ctx) => Promise<Response> | Response
*/✅ Summary
- Works natively in Node 24 and browsers
- Unified syntax for client + server routing
- Named parameters, regex validation, case control
- Great for lightweight APIs and service workers
- Performance: good enough for production, if not benchmark-perfect
The URLPattern API finally bridges browser and server routing — a clean, dependency-free way to define paths, parameters, and validation once for your entire stack.