npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@marianmeres/simple-router

v3.4.0

Published

[![NPM version](https://img.shields.io/npm/v/@marianmeres/simple-router)](https://www.npmjs.com/package/@marianmeres/simple-router) [![JSR version](https://jsr.io/badges/@marianmeres/simple-router)](https://jsr.io/@marianmeres/simple-router)

Readme

@marianmeres/simple-router

NPM version JSR version

A lightweight, framework-agnostic string pattern matcher and router with support for dynamic parameters, wildcards, query strings, and reactive subscriptions.

Can match any string identifiers - URLs, file paths, command names, or custom patterns. Originally inspired by Sapper-like regex routes. Primarily designed for client-side SPA routing, but flexible enough for any pattern matching needs.

Features

  • ✨ Dynamic route parameters with optional regex constraints
  • 🎯 Wildcard and catch-all routes
  • 📦 Spread parameters for multi-segment matching
  • 🔍 Query string parsing (with option to disable per route)
  • 🔄 Reactive subscriptions (Svelte store contract compatible)
  • 🪶 Zero dependencies (except @marianmeres/pubsub for subscriptions)
  • 📘 Full TypeScript support
  • 🎨 Framework-agnostic

Installation

deno add "jsr:@marianmeres/simple-router"
npm install @marianmeres/simple-router
import { SimpleRouter } from "@marianmeres/simple-router";

Quick Example

import { SimpleRouter } from "@marianmeres/simple-router";

// Routes can be defined via constructor config
const router = new SimpleRouter({
	"/": () => HomePage,
	"/about": () => AboutPage,
	"*": () => NotFoundPage, // catch-all fallback
});

// Or via the "on" API
router.on("/user/[id([0-9]+)]", (params) => {
	console.log("User ID:", params?.id);
	return UserPage(params?.id);
});

router.on("/article/[id]/[slug]", ({ id, slug }) => {
	return ArticlePage(id, slug);
});

// Execute route matching
const component = router.exec("/user/123");

// Use with hash routing
window.onhashchange = () => {
	const component = router.exec(location.hash.slice(1));
	render(component);
};

Route Patterns

Basic Segments

  • exact - Matches exactly "exact"
  • [name] - Matches any segment, captured as { name: "value" }
  • [name(regex)] - Matches if regex test passes
  • [name]? - Optional segment
  • [...name] - Spread params (matches multiple segments)
  • * - Wildcard (matches zero or more segments)

Separators

The default separator is /. By default (non-strict mode), multiple consecutive separators are collapsed into a single one, and separators are trimmed from both ends before matching. So /foo//bar and /foo/bar/ both resolve to /foo/bar.

If you need to treat consecutive separators as significant (for example, to canonicalize URLs and prevent accidental shadow-matching), enable strict mode:

// At router level (propagates to every route):
const router = new SimpleRouter({ strict: true, routes: { "/a/b": handler } });
router.exec("/a/b");   // matches
router.exec("/a//b");  // false (no match)

// Per-route override:
router.on("/legacy", handler, { strict: true });

// Or directly on SimpleRoute:
new SimpleRoute("/a/b", { strict: true });

Optional segments

Optional segments ([name]?) are only allowed in trailing position (or directly before a trailing wildcard *). Optional-followed-by-required patterns are rejected at construction:

new SimpleRoute("/foo/[bar]?/baz");
// Throws: optional segment '[bar]?' must not be followed by a required segment

// Instead, register two routes:
router.on("/foo/[bar]/baz", handler);
router.on("/foo/baz",       handler);

Pattern Examples

| Route Pattern | URL Input | Params Result | | ----------------------------- | ------------------ | -------------------------------- | | /foo | /bar | null (no match) | | /foo | (empty) | null | | /foo/[bar] | /foo | null | | / | (empty) | {} | | foo | foo | {} | | //foo///bar.baz/ | foo/bar.baz | {} | | /[foo] | /bar | { foo: "bar" } | | #/[foo]/[bar] | #/baz/bat | { foo: "baz", bar: "bat" } | | /[id([0-9]+)] | /123 | { id: "123" } | | /[id([0-9]+)] | /foo | null (regex fails) | | /foo/[bar]/[id([0-9]+)] | /foo/baz/123 | { bar: "baz", id: "123" } | | /foo/[bar]? | /foo | {} | | /foo/[bar]? | /foo/bar | { bar: "bar" } | | /[...path]/[file] | /foo/bar/baz.js | { path: "foo/bar", file: "baz.js" } | | /foo/* | /foo/bar/baz.js | {} | | /[foo]/* | /foo/bar | { foo: "foo" } |

API Reference

For the complete API documentation with all methods, types, and detailed examples, see API.md.

SimpleRouter

Constructor

// Simple config (backwards compatible)
const router = new SimpleRouter({
	"/": () => HomePage,
	"/about": () => AboutPage
});

// With options object (for logger support)
const router = new SimpleRouter({
	routes: {
		"/": () => HomePage,
		"/about": () => AboutPage
	},
	logger: myLogger // optional, compatible with @marianmeres/clog
});
  • config - Either a RouterConfig object mapping route patterns to callbacks, or a RouterOptions object with routes, logger, and strict properties

Methods

on(routes, callback, options?)

Register one or more route patterns with a callback.

router.on("/users", () => UsersPage);

// Multiple routes to same handler
router.on(["/", "/home", "/index.html"], () => HomePage);

// With dynamic params
router.on("/user/[id]", (params) => UserPage(params?.id));

// With regex constraint
router.on("/post/[id([0-9]+)]", (params) => PostPage(params?.id));

// With label for debugging
router.on("/admin", () => AdminPage, { label: "admin-dashboard" });

// Disable query param parsing for this route
router.on("/raw", (params) => RawPage, { allowQueryParams: false });

Options:

  • label - Optional label for debugging (visible via info())
  • allowQueryParams - Whether to parse query parameters (default: true)
  • strict - Per-route strict matching (overrides router default)

Important: Routes are matched in registration order. First match wins! When SimpleRouter.debug is enabled, a warning is logged at registration time if a new route would be shadowed by an already-registered one.

exec(url, fallbackFn?, options?)

Execute route matching against a URL.

const result = router.exec("/users");

// With fallback
router.exec("/unknown", () => console.log("Not found"));

// With query params
router.exec("/search?q=hello");

// Skip the internal "*" catch-all: returns false on a miss even if "*" is registered
router.exec("/unknown", undefined, { skipCatchAll: true });

Returns the value returned by the matched callback, or false if no match.

The optional { skipCatchAll: true } flag matches only real routes and returns false on a miss, instead of falling back to the registered * catch-all. An explicitly passed fallbackFn is unaffected. This is useful when fanning a single lookup across multiple routers ("try real routes in A, then B, … and fire a catch-all only if none matched") — see the catchAll getter for firing the deferred catch-all yourself.

subscribe(callback)

Subscribe to router state changes. Follows the Svelte store contract.

const unsubscribe = router.subscribe((state) => {
	console.log("Route:", state.route);
	console.log("Params:", state.params);
	console.log("Label:", state.label);
});

// Later
unsubscribe();

Returns an unsubscribe function directly. The callback is called immediately with the current state, then on every route change.

reset()

Clears all registered routes (except catch-all).

router.reset().on("/new-route", () => NewPage);
info()

Returns a map of registered routes to their labels (for debugging).

router.on("/users", () => {}, { label: "users-list" });
console.log(router.info()); // { "/users": "users-list" }

Properties

current

Gets the current router state (readonly).

router.exec("/user/123");
console.log(router.current);
// { route: "/user/[id]", params: { id: "123" }, label: null }
catchAll

Gets the registered catch-all (*) callback, or null if none was registered (readonly). Does not execute it — this lets you fire the catch-all as a deliberate final step (e.g. after a multi-router exec(url, undefined, { skipCatchAll: true }) pass) without re-scanning routes.

const fallback = router.catchAll;
if (fallback) fallback(null, "*");
static debug

Enable/disable debug logging. When enabled, uses the logger instance (if provided) or falls back to console.log.

SimpleRouter.debug = true;
router.exec("/test"); // Logs matching details to console (or custom logger)

SimpleRoute

Low-level route parser. Usually you don't need to use this directly.

import { SimpleRoute } from "@marianmeres/simple-router";

const route = new SimpleRoute("/user/[id([0-9]+)]");
const params = route.parse("/user/123");
console.log(params); // { id: "123" }

Static Methods

parseQueryString(str)

Parse a query string into an object.

SimpleRoute.parseQueryString("foo=bar&baz=123");
// Returns: { foo: "bar", baz: "123" }

TypeScript

Full TypeScript support with generic types for type-safe route callbacks and exec() return values.

Generic Router

SimpleRouter<T> is generic over T, the return type of route callbacks:

// Typed router - all callbacks must return Component, exec() returns Component | false
const router = new SimpleRouter<Component>({
	"/": () => HomePage,
	"/about": () => AboutPage,
	"*": () => NotFoundPage,
});

const result = router.exec("/about"); // Component | false
if (result !== false) {
	render(result); // result is Component
}

// Without explicit type - T is inferred from callbacks (or defaults to unknown)
const router2 = new SimpleRouter({
	"/": () => "home",
	"/about": () => "about",
});

Exported Types

import type {
	Logger,
	RouteParams,
	RouteCallback,      // RouteCallback<T = unknown>
	RouterConfig,       // RouterConfig<T = unknown>
	RouterCurrent,
	RouterOnOptions,
	RouterOptions,      // RouterOptions<T = unknown>
	RouterSubscriber,
	RouterUnsubscribe,
	SimpleRouteOptions, // { strict?: boolean }
} from "@marianmeres/simple-router";

Advanced Examples

SPA with Hash Routing

// Type-safe router with Component return type
const router = new SimpleRouter<Component>({
	"/": () => HomePage,
	"/about": () => AboutPage,
	"/user/[id]": (params) => UserPage(params?.id),
	"*": () => NotFoundPage,
});

function render(component: Component) {
	document.getElementById("app").innerHTML = component.render();
}

window.addEventListener("hashchange", () => {
	const path = location.hash.slice(1) || "/";
	const component = router.exec(path); // Component | false
	if (component !== false) {
		render(component);
	}
});

// Trigger initial render
window.dispatchEvent(new HashChangeEvent("hashchange"));

Route Priority

Routes are matched in registration order (first match wins):

const router = new SimpleRouter();

// Register generic route first
router.on("/user/[id]", (params) => {
	console.log("Generic:", params?.id); // This will match
});

// Specific route registered second (won't match "/user/admin")
router.on("/user/admin", () => {
	console.log("Admin"); // This won't be reached
});

router.exec("/user/admin"); // Logs: "Generic: admin"

To fix, register more specific routes first:

router.on("/user/admin", () => console.log("Admin"));
router.on("/user/[id]", (params) => console.log("User:", params?.id));

Beyond URLs: General Pattern Matching

The router can match any string patterns, not just URLs:

// File path routing
const fileRouter = new SimpleRouter({
	"src/[module]/[file].ts": ({ module, file }) =>
		console.log(`Module: ${module}, File: ${file}`),
	"assets/images/[...path]": ({ path }) =>
		console.log(`Image path: ${path}`),
});

fileRouter.exec("src/components/Button.ts");
// Logs: "Module: components, File: Button"

fileRouter.exec("assets/images/icons/user.png");
// Logs: "Image path: icons/user.png"

// Command routing
const cmdRouter = new SimpleRouter({
	"user:create": () => createUser(),
	"user:delete:[id([0-9]+)]": ({ id }) => deleteUser(id),
	"cache:clear:[type(redis|memcached)]?": ({ type = "all" }) =>
		clearCache(type),
});

cmdRouter.exec("user:delete:123");
cmdRouter.exec("cache:clear:redis");
cmdRouter.exec("cache:clear"); // type defaults to "all"

// Custom separator (anything that's not a special regex char works)
const dotRouter = new SimpleRouter({
	"app.settings.theme": () => "Theme settings",
	"app.settings.[section]": ({ section }) => `Settings: ${section}`,
});

dotRouter.exec("app.settings.profile");
// Returns: "Settings: profile"

Security notes

Regex constraints inside a route pattern (e.g. [id([0-9]+)]) are compiled with RegExp, so any valid JavaScript regex is accepted. A handful of patterns — typically nested quantifiers such as (a+)+ — can exhibit catastrophic backtracking (ReDoS) on crafted inputs.

  • Route patterns are author-controlled: never accept them from untrusted input.
  • Prefer simple character classes ([0-9]+, [a-z]+) over nested quantifiers.
  • If you need a complex constraint, test it against adversarial inputs first.

Breaking changes

3.2.0

  • Optional-followed-by-required is now rejected at construction. Patterns like /foo/[bar]?/baz previously parsed but silently treated the optional marker as required — /foo/baz never matched. Such patterns now throw with a clear error. Register two separate routes instead (/foo/[bar]/baz and /foo/baz).
  • Parameter names are no longer URI-decoded. A pattern like /foo/[id%20x] used to produce the key id x in the params object; it now produces id%20x. Param values are still decoded. Parameter names in a named constraint ([name(regex)]) must now match \w+.
  • Regex constraints with parentheses now work correctly. Patterns like /[id((?:a|b)+)] previously produced garbage regexes or threw obscure "Invalid regular expression" errors; they now parse and match as expected.