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

react-routes-forge

v1.3.0

Published

Type-safe route definitions, automatic path builders, query parameter handling, and active route matching for React applications with zero duplication.

Readme

react-routes-forge

Type-safe route definitions, automatic path builders, query parameter handling, and active route matching for React applications with zero duplication.

📖 Documentation | 🚀 Live Demo (POC)

Documentation Live Demo License: MIT TypeScript Node.js 18+ Combined CI/CD


Table of contents


Why react-routes-forge?

Most React apps end up with route definitions like this:

// ❌ The common pattern
export const PATHS = {
  USERS: {
    ROOT: "/users",
    DETAILS: "/users/:id",
  },
};

Dynamic routes typically need a second entry alongside the template — a hand-written function to build the real URL. As the app grows, the two drift apart, and nothing stops the template and the builder from disagreeing.

react-routes-forge collapses both into a single key:

// ✅ One key, two uses
export const PATHS = defineRoutes({
  USERS: {
    ROOT: "/users",
    DETAILS: "/users/:id",
  },
} as const);

PATHS.USERS.ROOT; // '/users'            → static, used directly
PATHS.USERS.DETAILS; // '/users/:id'        → use in <Route path={...} />
PATHS.USERS.DETAILS.build({ id: 42 }); // '/users/42'        → use when navigating

You get:

  • Single source of truth — no duplicate template/builder pairs to keep in sync
  • Compile-time param safety.build() is typed from the path string itself; missing or misspelled params are TypeScript errors
  • Query string support — built into .build(), no manual URLSearchParams wrangling
  • Zero runtime dependencies for the core API
  • Deep nesting supported out of the box — organize routes into as many nested groups as your app needs

Installation

npm install react-routes-forge
# or
pnpm add react-routes-forge
# or
yarn add react-routes-forge
# or
bun add react-routes-forge

Note: this package ships dual ESM + CommonJS builds. See Known behaviours & gotchas for details.


Quick start

// paths.ts
import { defineRoutes } from "react-routes-forge";

export const PATHS = defineRoutes({
  HOME: "/",
  LOGIN: "/login",
  USERS: {
    ROOT: "/users",
    ADD: "/users/add",
    EDIT: "/users/edit/:id",
    DETAILS: "/users/:id",
  },
  ROLES: {
    PERMISSIONS: "/roles/permissions/:name",
  },
} as const);

Always pass as const — it preserves the literal string types that power .build()'s compile-time param checking. Without it, TypeScript widens your path strings to generic string and you lose type safety.

// App.tsx — static paths and dynamic templates both work directly as strings
import { Routes, Route } from "react-router-dom";
import { PATHS } from "./paths";

<Routes>
  <Route path={PATHS.HOME} element={<Home />} />
  <Route path={PATHS.USERS.ROOT} element={<UserList />} />
  <Route path={PATHS.USERS.EDIT} element={<EditUser />} />
  <Route path={PATHS.ROLES.PERMISSIONS} element={<RolePermissions />} />
</Routes>;
// Navigating — call .build() to resolve a dynamic path into a real URL
import { useNavigate } from "react-router-dom";

function MyComponent() {
  const navigate = useNavigate();
  //                    ↓ Param type-checked from the template ":id"
  navigate(PATHS.USERS.EDIT.build({ id: 42 })); // → '/users/edit/42'
  navigate(PATHS.ROLES.PERMISSIONS.build({ name: "admin" })); // → '/roles/permissions/admin'
  navigate(PATHS.HOME); // → '/'  (static paths work directly)
}

That's the entire API surface you need for most apps. Everything below covers the rest of the toolkit.

Route types

| Route type | Example | Behaves as | Gains | | ----------- | ----------------------- | --------------------------------------- | --------------------------------------------------------------------- | | Static | HOME: '/' | String-like (coercible to its template) | .build(query?, options?) — attach query/hash, no params to fill | | Dynamic | DETAILS: '/users/:id' | String-like (coercible to its template) | .build(params, query?, options?) and .paramNames | | Splat | FILES: '/files/*' | String-like (coercible to its template) | .build(params, query?, options?) and .paramNames |

defineRoutes() walks your route object recursively, wrapping every path in a string-coercible object and attaching a .build() helper so both static and dynamic routes can carry a query string or hash. Dynamic paths (containing a :param segment or a trailing /* splat) additionally gain .paramNames.

Param names are [A-Za-z0-9_] only (matching React Router), so a static suffix after a param stays literal — /files/:name.json builds { name: "report" }/files/report.json, and :name.json is not treated as a single param name.

defineRoutes() also validates your templates in development — missing leading /, non-trailing *, and duplicate path templates all produce a console.warn. See Route validation.


Cheat sheet

Quick reference for everything the package exports — grouped by kind. Click through to the full section for details and examples.

Route definition

| Export | Purpose | | ------------------------------------------------- | ------------------------------------------------------- | | defineRoutes(routeMap) | Builds the typed PATHS object from a nested route map | | .build(query?, options?) | On every static route — attach query string / hash | | .build(params, query?, options?) | On every dynamic route — resolves to a concrete URL | | .paramNames | On every dynamic route — the param names it expects |

Utilities

| Export | Purpose | | ---------------------------------------------------------------------------------------------- | ---------------------------------------------------------- | | build(template, params, query?, options?) | Resolve a template into a URL without defineRoutes | | isActivePath(currentPath, template, options?) | Check if a path matches a template (nav-highlighting) | | extractParamsFromPath(template, resolvedPath) | Pull param values back out of a resolved URL | | matchPath(template, options?) | Convert a route template into an anchored RegExp | | joinPaths(...segments) | Join and normalize path segments | | getParamNames(template) | List the :param names in a template | | flattenRoutes(routes) | Flatten a PATHS tree for sitemaps / duplicate detection | | getBreadcrumbs(routes, currentPath, options?) | Build a breadcrumb trail from a route tree and current URL | | appendQuery(path, query?, hash?) | Append query params / hash to an existing path | | extractQueryFromPath(path, options?) | Parse a query string back into an object | | devWarn(message) | Emit a console.warn in non-production builds | | clearPathCache() | Reset internal regex caches (mainly for tests) |

React hooks

| Export | Purpose | | ----------------------------------------------------------------------- | ------------------------------------------------------------- | | useRouteParams<T>() | Typed wrapper around React Router's useParams | | useNavigateTo() | Typed wrapper around React Router's useNavigate | | useResolvedPath(...) | Resolve a template to a string without navigating | | useActivePath(template, options?) | Check if the current location matches a route template | | useTypedSearchParams(options?) | Typed useSearchParams with boolean/number coercion |


API reference

defineRoutes(routeMap)

Creates a fully typed route object from a nested plain object.

  • Every path is string-coercible — use it directly anywhere a string is expected (e.g. <Route path={...} />).
  • Static paths gain .build(query?, options?) — attach a query string and/or hash fragment without params.
  • Dynamic paths (containing :param) and splat paths (trailing /*) gain:
    • .build(params, query?, options?) — resolves the template into a concrete URL
    • .paramNames — array of the param names extracted from the template, e.g. ['id'] (a splat is reported as ['*'])

Nesting is unlimited — organize routes into as many groups and sub-groups as your app needs.

const PATHS = defineRoutes({
  SERVICES: {
    ROOT: "/services",
    SUPPORT_CENTER: {
      DETAILS: "/services/support-center/:id",
      EDIT: "/services/support-center/edit/:id",
    },
  },
} as const);

PATHS.SERVICES.ROOT; // '/services'
PATHS.SERVICES.SUPPORT_CENTER.EDIT.build({ id: 7 }); // '/services/support-center/edit/7'
PATHS.SERVICES.SUPPORT_CENTER.EDIT.build(
  { id: 7 },
  { tab: "info" },
  { hash: "details" },
); // → '/services/support-center/edit/7?tab=info#details'
PATHS.SERVICES.SUPPORT_CENTER.EDIT.paramNames; // ['id']

Always pass as const to defineRoutes() — it preserves the literal string types that power .build()'s compile-time param checking.


build(template, params, query?, options?)

Standalone path resolver — for building a URL without going through defineRoutes, or for resolving a raw template string (rather than a route from a PATHS tree).

import { build } from "react-routes-forge";

build("/users/:id/posts/:postId", { id: 1, postId: 42 });
// → '/users/1/posts/42'

// Append a query string to any path — including static ones
build("/users", {}, { sort: "asc" });
// → '/users?sort=asc'

// Strict mode — throw instead of warn when a param is missing
build("/users/:id", {}, undefined, { strict: true });
// ✗ throws RangeError: [route-forge] Missing required param(s) ":id" in template "/users/:id".

// Hash fragment — appended after the query string
build("/users/:id", { id: 42 }, { tab: "info" }, { hash: "details" });
// → '/users/42?tab=info#details'
build("/page", {}, undefined, { hash: "section" });
// → '/page#section'

Param values are URL-encoded by default (encodeURIComponent), so characters like /, ?, #, or % in a value can't break the URL structure:

build("/search/:query", { query: "a/b" });
// → '/search/a%2Fb'

// Pass { encode: false } if a value is already encoded
build("/search/:query", { query: "a%2Fb" }, undefined, { encode: false });
// → '/search/a%2Fb'

// Splat segments capture a path-like remainder, preserving `/` separators
build("/files/*", { "*": "reports/2026/q1" });
// → '/files/reports/2026/q1'

See Splat (/*) segments for details.


isActivePath(currentPath, template, options?)

Checks whether a resolved path matches a route template — the building block for nav-highlighting ("is this link active?"). Query strings on currentPath are ignored automatically. It mirrors React Router's NavLink matching semantics:

  • Case-insensitive by default — pass { caseSensitive: true } to opt out.
  • Trailing slashes are tolerated/users/ matches /users.
  • exact: true (default) requires a full match; exact: false matches any path that starts with the template (so / matches every path as a prefix).
import { isActivePath } from "react-routes-forge";

isActivePath("/users/42", "/users/:id"); // true
isActivePath("/users/42/posts", "/users/:id"); // false (exact match by default)
isActivePath("/users/42/posts", "/users/:id", { exact: false }); // true  (prefix match)
isActivePath("/users/42?tab=profile", "/users/:id"); // true  (query string ignored)
isActivePath("/Users/42", "/users/:id"); // true  (case-insensitive by default)
isActivePath("/Users/42", "/users/:id", { caseSensitive: true }); // false
isActivePath("/users/42/", "/users/:id"); // true  (trailing slash tolerated)

A common real-world use — highlighting the active nav link:

function NavLink({ to, children }: { to: string; children: React.ReactNode }) {
  const location = useLocation();
  const active = isActivePath(location.pathname, to, { exact: false });

  return (
    <Link to={to} className={active ? "nav-link active" : "nav-link"}>
      {children}
    </Link>
  );
}

extractParamsFromPath(template, resolvedPath)

Extracts param values back out of a resolved URL, given its template. Also strips query strings before matching.

import { extractParamsFromPath } from "react-routes-forge";

extractParamsFromPath("/users/:id", "/users/42");
// → { id: '42' }

extractParamsFromPath("/a/:x/b/:y", "/a/foo/b/bar");
// → { x: 'foo', y: 'bar' }

matchPath(template, options?)

Converts a route template string into an anchored RegExp — useful when you need custom matching logic beyond isActivePath or extractParamsFromPath. Query strings are not stripped; split on "?" first if needed.

import { matchPath } from "react-routes-forge";

const re = matchPath("/users/:id");
re.test("/users/42"); // true
re.exec("/users/42"); // ['/users/42', '42']
re.test("/users/42/posts"); // false (exact match only)

Options:

  • end?: boolean (default true) — anchor the pattern to the end of the path. Pass false to match a prefix at a segment boundary (/users matches /users/42 but not /usersettings).
  • caseSensitive?: boolean (default false) — match case-insensitively by default; pass true to opt out.
matchPath("/users/:id", { end: false }).test("/users/42/posts"); // true
matchPath("/Users/42", { caseSensitive: true }).test("/users/42"); // false

This is the building block used internally by isActivePath and extractParamsFromPath.


joinPaths(...segments)

Safely joins path segments, normalizing duplicate/missing slashes.

import { joinPaths } from "react-routes-forge";

joinPaths("/users", "edit", ":id"); // → '/users/edit/:id'
joinPaths("/api/", "/v1/", "/users"); // → '/api/v1/users'

getParamNames(template)

Returns the list of param names present in a template string.

import { getParamNames } from "react-routes-forge";

getParamNames("/users/:id/posts/:postId"); // → ['id', 'postId']
getParamNames("/files/*"); // → ['*']  (the splat param)
getParamNames("/users"); // → []

flattenRoutes(routes)

Walks a defineRoutes() tree and returns a flat array of { key, path } entries, where key is the dot-joined path from the root (e.g. "SERVICES.BENEFICIARY_CARE_CENTER.EDIT") and path is the raw template string.

Primary uses:

  • Sitemap generation — one call gives you every route in the app.
  • Duplicate detection — catch the same path string defined under two different keys before it ships.
import { defineRoutes, flattenRoutes } from "react-routes-forge";

const PATHS = defineRoutes({
  HOME: "/",
  USERS: {
    ROOT: "/users",
    EDIT: "/users/edit/:id",
  },
} as const);

flattenRoutes(PATHS);
// [
//   { key: 'HOME',       path: '/' },
//   { key: 'USERS.ROOT', path: '/users' },
//   { key: 'USERS.EDIT', path: '/users/edit/:id' },
// ]

// Detect duplicate paths across the tree
const flat = flattenRoutes(PATHS);
const paths = flat.map((r) => r.path);
const dupes = paths.filter((p, i) => paths.indexOf(p) !== i);
if (dupes.length) console.warn("Duplicate route paths:", dupes);

A useful pattern is running the duplicate check once at app startup (or in a test) so a copy-paste route collision fails fast instead of surfacing as a confusing routing bug later:

// routes.test.ts
it("has no duplicate route paths", () => {
  const paths = flattenRoutes(PATHS).map((r) => r.path);
  const dupes = paths.filter((p, i) => paths.indexOf(p) !== i);
  expect(dupes).toEqual([]);
});

getBreadcrumbs(routes, currentPath, options?)

Walks a route tree (or a pre-flattened array from flattenRoutes()) and returns every route that is an ancestor of (or an exact match to) the current URL. Dynamic params in ancestor paths are automatically resolved from the matched portion of the URL. Query strings on currentPath are ignored.

Each breadcrumb entry contains:

  • key — the dot-joined key from the route tree (e.g. "USERS.EDIT")
  • label — a human-readable label derived from the key (e.g. "USERS.ROOT""Users", "USERS.EDIT""Edit")
  • path — the resolved breadcrumb path with params filled in (e.g. "/users/edit/42")
  • isCurrenttrue only for the deepest (exact) match
import { defineRoutes, getBreadcrumbs } from "react-routes-forge";

const PATHS = defineRoutes({
  HOME: "/",
  USERS: {
    ROOT: "/users",
    EDIT: "/users/edit/:id",
  },
  SERVICES: {
    BCC: {
      EDIT: "/services/bcc/edit/:id",
    },
  },
} as const);

getBreadcrumbs(PATHS, "/users/edit/42");
// →
// [
//   { key: "HOME",       label: "Home",  path: "/",             isCurrent: false },
//   { key: "USERS.ROOT", label: "Users", path: "/users",        isCurrent: false },
//   { key: "USERS.EDIT", label: "Edit",  path: "/users/edit/42", isCurrent: true  },
// ]

Custom label resolver — override the default key-to-label conversion:

getBreadcrumbs(PATHS, "/users/edit/42", {
  labelResolver: (key) =>
    key.split(".").pop()!.replace(/_/g, " ").toUpperCase(),
});
// → [{ label: "HOME" }, { label: "ROOT" }, { label: "EDIT" }]

Label map — the ergonomic alternative for a handful of overrides. Keys are dot-joined route keys; matching keys take precedence over labelResolver:

getBreadcrumbs(PATHS, "/users/edit/42", {
  labels: { "USERS.ROOT": "Members", "USERS.EDIT": "Edit member" },
});
// → [{ label: "Home" }, { label: "Members" }, { label: "Edit member" }]

Pre-flattened input — pass a cached flattenRoutes() result instead of the tree:

const flat = flattenRoutes(PATHS);
getBreadcrumbs(flat, "/users/edit/42"); // same result as passing the tree

appendQuery(path, query?, hash?)

Appends a query string and/or hash fragment to a path that may already contain a query or hash. Existing query pairs are preserved, the query is inserted before any hash, and an existing hash is kept unless a new one is given.

import { appendQuery } from "react-routes-forge";

appendQuery("/users?tab=list", { page: 2 }); // → '/users?tab=list&page=2'
appendQuery("/users#top", { tab: "list" }); // → '/users?tab=list#top'
appendQuery("/users", { active: true }); // → '/users?active=true'
appendQuery("/users", { tag: ["a", "b"] }); // → '/users?tag=a&tag=b'

This is the same helper every path-resolving function uses internally.


extractQueryFromPath(path, options?)

Parses the query string out of a path (or bare query string) back into a plain object. Repeated keys become arrays; single keys are scalar strings.

Options:

  • coerceBooleans?: boolean — convert the strings "true"/"false" to real booleans.
  • coerceNumbers?: boolean — convert numeric strings ("42", "3.14") to real numbers.
import { extractQueryFromPath } from "react-routes-forge";

extractQueryFromPath("/users/42?tab=profile&tag=a&tag=b");
// → { tab: "profile", tag: ["a", "b"] }

extractQueryFromPath("/search?active=true", { coerceBooleans: true });
// → { active: true }

extractQueryFromPath("/search?page=2&limit=10", { coerceNumbers: true });
// → { page: 2, limit: 10 }

devWarn(message)

Emits a console.warn in non-production environments. Shared by the core utilities and defineRoutes() so the production check lives in one place.

import { devWarn } from "react-routes-forge";

devWarn("[route-forge] Something looks wrong.");
// → console.warn in dev/test, silent in production bundles

clearPathCache()

Clears the internal regex caches used by matchPath() / prefix matching. Primarily useful in test suites to prevent cached patterns from leaking across test cases.

import { clearPathCache } from "react-routes-forge";

beforeEach(() => {
  clearPathCache();
});

React hooks

Import these only if you're using React Router — they live in a separate react-routes-forge/hooks entry, so the core package never pulls in react-router-dom.

useRouteParams<T>()

Typed wrapper around React Router's useParams. Pass the route's template string as a generic to get a correctly typed params object back — no casting, and it works for any number of :param segments. Alternatively, pass a dynamic route value from your PATHS tree and the params are inferred from it automatically:

import { useRouteParams } from "react-routes-forge/hooks";

// Route: '/users/edit/:id'
function EditUser() {
  const { id } = useRouteParams<"/users/edit/:id">();
  return <div>Editing user {id}</div>;
}

// Multiple params also work correctly
// Route: '/posts/:postId/comments/:commentId'
function Comment() {
  const { postId, commentId } =
    useRouteParams<"/posts/:postId/comments/:commentId">();
  // ...
}

// Or pass a route from your PATHS tree — types are inferred:
const PATHS = defineRoutes({ USERS: { EDIT: "/users/edit/:id" } } as const);
function EditUserInferred() {
  const { id } = useRouteParams(PATHS.USERS.EDIT);
  // ...
}

useNavigateTo()

Thin, typed wrapper around useNavigate() that accepts a resolved path (the output of .build()) along with the usual navigation options.

import { useNavigateTo } from "react-routes-forge/hooks";
import { PATHS } from "./paths";

function Component() {
  const navigateTo = useNavigateTo();

  return (
    <button onClick={() => navigateTo(PATHS.USERS.EDIT.build({ id: 42 }))}>
      Edit
    </button>
  );
}

navigateTo(PATHS.HOME, { replace: true });
navigateTo(PATHS.USERS.ROOT, { state: { from: "settings" } });

useResolvedPath(template, params, query?, options?)

Resolves a path template to a concrete URL string without navigating — useful for <Link to={...} />, preloading, or building a URL for something other than navigate(). It mirrors the library's own build(), so splat (*) and optional (:param?) segments work identically to the core API — and the encoding/strict behaviour is consistent across React Router v6 and v7. Accepts the same query and options as build().

import { useResolvedPath } from "react-routes-forge/hooks";

const path = useResolvedPath("/users/:id", { id: 42 });
// → '/users/42'

const path = useResolvedPath("/users/:id", { id: 42 }, { tab: "info" });
// → '/users/42?tab=info'

// Splat segments are preserved
const path = useResolvedPath("/files/*", { "*": "a/b/c" });
// → '/files/a/b/c'

// Strict mode — throws RangeError instead of warning on missing params
const path = useResolvedPath("/users/:id", {}, undefined, { strict: true });

// With hash fragment
const path = useResolvedPath("/page", {}, undefined, { hash: "section" });
// → '/page#section'

useActivePath(template, options?)

A hook that checks whether the current location matches a route template or path — a thin wrapper around isActivePath() that reads the location from the router. Same matching semantics: case-insensitive by default, trailing slashes tolerated, exact: true by default.

import { useActivePath } from "react-routes-forge/hooks";

function Nav() {
  const isUsersActive = useActivePath(PATHS.USERS.ROOT, { exact: false });
  const isProfileActive = useActivePath("/users/:id", { caseSensitive: true });

  return (
    <Link className={isUsersActive ? "active" : ""} to={PATHS.USERS.ROOT}>
      Users
    </Link>
  );
}

useTypedSearchParams(options?)

A typed wrapper around React Router's useSearchParams. Returns a parsed query params object (using extractQueryFromPath()) and a setter that updates the query string. The same coercion options are supported: { coerceBooleans: true } and { coerceNumbers: true }.

import { useTypedSearchParams } from "react-routes-forge/hooks";

function Filters() {
  const [query, setQuery] = useTypedSearchParams({
    coerceBooleans: true,
    coerceNumbers: true,
  });

  // query.page is a number when the URL is '/search?page=2'
  const nextPage = (query.page ?? 0) + 1;
  setQuery({ ...query, page: nextPage });

  // Clear a filter by omitting it (or pass null/undefined)
  setQuery({ page: 1, sort: "asc" });
}

Splat (/*) segments

Splat routes (/files/*) capture the rest of the path — including slashes — into a single * param, matching React Router semantics. Supported across the entire core API, not just the hooks.

import { defineRoutes, build, isActivePath, extractParamsFromPath } from "react-routes-forge";

const PATHS = defineRoutes({
  FILES: "/files/*",
} as const);

PATHS.FILES.build({ "*": "reports/2026/q1" }); // → '/files/reports/2026/q1'
String(PATHS.FILES); // → '/files/*'
PATHS.FILES.paramNames; // → ['*']

Behaviour notes:

  • Slashes in the value are preserved (they're path separators); other special characters are still URL-encoded — "/files/*" with "a b/c?d""/files/a%20b/c%3Fd".
  • A missing splat value drops the /* suffix/files/* resolves to /files (matching React Router, where the splat route also matches the base path).
  • isActivePath("/files/a/b", "/files/*")true; extractParamsFromPath("/files/*", "/files/a/b"){ "*": "a/b" }.
  • A splat must be trailing (/files/*). A * in the middle of a path is invalid and produces a dev warning (see below).

Route validation

defineRoutes() validates every template in development (no-op in production) and warns via console.warn about likely mistakes:

| Problem | Example | Warning | | ------- | ------- | ------- | | Missing leading / | "users/:id" | does not start with "/" | | Non-trailing splat | "/files/*/extra" | * outside a trailing "/*" | | Duplicate path template | FOO: "/foo" and BAR: "/foo" | Duplicate route path "/foo" (names both keys) | | Static route shadowed by a dynamic route above it | DETAILS: "/users/:id" defined before ME: "/users/me" | "ME" is shadowed by dynamic route "DETAILS" |

// duplicate route paths are caught at startup instead of as a routing bug later
defineRoutes({
  A: { FOO: "/foo" },
  B: { FOO: "/foo" },
} as const);
// ⚠ console.warn: [route-forge] Duplicate route path "/foo" for "A.FOO" and "B.FOO". Only one of them will be reachable.

These are warnings, not errors — invalid routes still build, so a broken definition can't crash your app at import time. Run a stricter check once in tests if you want duplicates to fail the build:

it("has no duplicate route paths", () => {
  const paths = flattenRoutes(PATHS).map((r) => r.path);
  expect(paths).toEqual([...new Set(paths)]);
});

Query string support

Every path-resolving function — .build(), build(), and useResolvedPath() — accepts an optional query object as its second-to-last argument.

navigate(
  PATHS.USERS.DETAILS.build({ id: 42 }, { tab: "billing", sort: "asc" }),
);
// → '/users/42?tab=billing&sort=asc'

Array values are serialized as repeated keys:

build("/search", {}, { tags: ["admin", "moderator"] });
// → '/search?tags=admin&tags=moderator'

Boolean values serialize to "true"/"false":

build("/search", {}, { active: true, draft: false });
// → '/search?active=true&draft=false'

null and undefined values are dropped, so you can pass optional filters without conditionally building the object:

build("/users", {}, { sort: "asc", filter: undefined });
// → '/users?sort=asc'

Static routes have a .build() too — there are no params to interpolate, but you can still attach a query string or hash:

PATHS.USERS.ROOT.build({ sort: "asc", page: 2 });
// → '/users?sort=asc&page=2'

// The standalone build() util works the same way for raw templates:
import { build } from "react-routes-forge";
build(PATHS.USERS.ROOT, {}, { sort: "asc", page: 2 });
// → '/users?sort=asc&page=2'

Reading query params back out is handled by extractQueryFromPath(path, options?), and appending to an existing URL (e.g. a link with pre-set filters) by appendQuery(path, query?, hash?).


Hash fragment support

URL hash fragments (#section) are supported in every path-resolving function — .build(), build(), and useResolvedPath() — via the hash option. The hash is appended after the query string, if any.

// Via fluent .build() on a dynamic route
PATHS.USERS.DETAILS.build({ id: 42 }, undefined, { hash: "profile" });
// → '/users/42#profile'

// With query + hash
PATHS.USERS.DETAILS.build({ id: 42 }, { tab: "info" }, { hash: "details" });
// → '/users/42?tab=info#details'

// Via standalone build()
build("/page", {}, undefined, { hash: "section" });
// → '/page#section'

// Via useResolvedPath
useResolvedPath(
  "/users/:id",
  { id: 5 },
  { tab: "billing" },
  { hash: "invoice" },
);
// → '/users/5?tab=billing#invoice'

The leading # is added automatically — pass just the fragment name (e.g. "details", not "#details").


Strict mode

By default, a missing required param leaves the :param placeholder in the resolved string and logs a console.warn — useful for catching bugs during development without crashing the app.

Pass { strict: true } as the last argument to any builder to throw a RangeError instead:

build("/users/:id", {}, undefined, { strict: true });
// ✗ throws RangeError: [route-forge] Missing required param(s) ":id" in template "/users/:id".

This is consistent across the whole API surface:

| API | Default (no strict) | { strict: true } | | -------------------------------------- | --------------------------------------------- | ------------------- | | .build() (fluent, on dynamic routes) | console.warn, leaves :param in the string | throws RangeError | | build() / buildPath() (standalone) | console.warn, leaves :param in the string | throws RangeError | | useResolvedPath() | console.warn, leaves :param in the string | throws RangeError |

A common pattern is enabling strict mode only in tests or development builds:

const opts = { strict: process.env.NODE_ENV === "test" };
navigate(PATHS.USERS.EDIT.build({ id: userId }, undefined, opts));

Migrating from the old pattern

If your routes currently look like this:

// ❌ Before
export const PATHS = {
  SERVICES: {
    ROOT: "/services",
    DETAILS: "/services/:id",
  },
};

navigate(`/services/${id}`);
navigate(`${PATHS.SERVICES.ROOT}/${id}`);

Drop the manual string concatenation and wrap the object in defineRoutes():

// ✅ After
export const PATHS = defineRoutes({
  SERVICES: {
    ROOT: "/services",
    DETAILS: "/services/:id",
  },
} as const);

Update call sites to use .build() instead of template literals or hand-written helper functions:

// Before
navigate(`/services/${id}`);
navigate(`${PATHS.SERVICES.ROOT}/${id}`);

// After
navigate(PATHS.SERVICES.DETAILS.build({ id }));

Everywhere the template string itself was used (e.g. <Route path={PATHS.SERVICES.DETAILS} />) needs no changes at all.


Known behaviours & gotchas

Routes are String objects, not primitives

defineRoutes wraps every path — static, dynamic, and splat — in String objects so that .build() (and .paramNames on dynamic routes) can be attached as properties. This means:

// ✓ These all work as expected
String(PATHS.HOME); // '/'
`${PATHS.USERS.EDIT}`; // '/users/edit/:id'
PATHS.USERS.EDIT == "/users/edit/:id"; // true  (loose equality)

// ✗ Watch out for these
typeof PATHS.HOME; // 'object' ← not 'string'
PATHS.HOME === "/"; // false    ← strict equality fails

Prefer template literals or explicit String() coercion when comparing route values, and avoid using them as plain object/Map keys.

useResolvedPath vs. the library's own buildPath

useResolvedPath is a thin wrapper around the library's own buildPath, so splat (*), optional (:param?) and encoding behaviour are identical across every entry point — and consistent across React Router v6 and v7 (v7's generatePath URL-encodes values itself, which would otherwise double-encode).

ESM + CommonJS builds

This package ships both ESM and CommonJS bundles (dist/index.js for ESM, dist/index.cjs for CJS), with exports conditions routing each environment to the right format. Modern bundlers use the ESM build; Node.js require() gets the CommonJS build automatically. Consumers on plain CommonJS are fully supported.


TypeScript support

Written in strict TypeScript with no any in the public API surface. Param types for .build() are inferred directly from each path template via a recursive template-literal type, so:

PATHS.USERS.EDIT.build({ id: 42 }); // ✓ compiles
PATHS.USERS.EDIT.build({}); // ✗ compile error — 'id' is required
PATHS.USERS.EDIT.build({ userId: 42 }); // ✗ compile error — 'id' expected, not 'userId'

.paramNames is similarly typed as a literal array of the exact param names in the template, not a generic string[].

To annotate a plain route object (e.g. a shared constant used by defineRoutes()), import the RouteTree type:

import type { RouteTree } from "react-routes-forge";

const routes: RouteTree = {
  HOME: "/",
  USERS: { ROOT: "/users", EDIT: "/users/edit/:id" },
};

The result of defineRoutes() is typed as ResolvedRoutes, and individual leaves are StaticRoute<T> (static paths, with .build(query?, options?)) or DynamicRoute<T> (dynamic/splat paths, with .build(params, ...) and .paramNames) — all exported as types if you need to reference them. MatchPathOptions types the matchPath() options bag.


Testing

The package ships with a full test suite covering the core builder/utility functions and the React hooks, including strict-mode behaviour, query string edge cases (arrays, booleans, null/undefined filtering), splat segments, route validation, nested route groups, and duplicate-path detection.

npm test                # run the full suite once (bun test)
npm run test:watch      # watch mode
npm run test:coverage   # run with coverage (vitest + v8, outputs lcov.info)

# equivalent with other package managers
pnpm test / pnpm test:watch
yarn test / yarn test:watch
bun test / bun test:watch

CI runs the suite across a matrix of Node.js versions (18 / 20 / 22 / 24) and React Router v6 and v7, plus lint, a production build, and a coverage job that uploads lcov.info as a build artifact (see .github/workflows/ci-security.yml).

If you're contributing, new behaviour should come with a matching test — the existing suite is organized by function/hook, so add cases alongside the relevant describe block rather than starting a new file.


Requirements

  • React ≥ 17 (peer dependency)
  • react-router-dom ≥ 6 (optional peer dependency — required only for the react-routes-forge/hooks entry)
  • Node.js ≥ 18
  • TypeScript ≥ 5 recommended for full type inference (the package works with plain JavaScript too, just without compile-time param checking)

Contributing

Issues and pull requests are welcome.

  1. Fork the repo and create a branch for your change.
  2. Add or update tests for any behavioural change — see Testing.
  3. Run npm run lint and npm test before opening a PR.
  4. Keep commit messages conventional (feat:, fix:, docs:, …) — this repo uses standard-version for releases.

License

MIT