Skip to content

Repository files navigation

📍 unrouting

npm version npm downloads bundle Codecov License JSDocs

Universal filesystem routing

unrouting parses file paths into a route tree and emits route definitions for any framework router. It handles nested routes, dynamic params, catchalls, optional segments, route groups, layer merging, and more – as a standalone, framework-agnostic library.

Status

In active development. The core pipeline (parse, tree, emit) is functional and used by Nuxt.

  • Generic route parsing covering major filesystem routing patterns
  • Route tree with nesting, layer merging, group transparency
  • Layer priority (multiple roots with configurable file precedence)
  • Incremental tree updates (addFile/removeFile for dev server HMR)
  • Pluggable route name generation
  • Route ordering by segment priority (static > dynamic > optional > catchall)
  • Named view support (@viewName convention)
  • Mode variant support (.client, .server, configurable)
  • Duplicate route name detection
  • Emit to framework routers

Install

# npm
npm install unrouting

# pnpm
pnpm install unrouting

Usage

The library has a three-phase pipeline: parse file paths into tokens, build a route tree, and emit to a target format. For most use cases you only need two function calls.

Quick start

import { buildTree, toVueRouter4 } from 'unrouting'

const tree = buildTree([
  'pages/index.vue',
  'pages/about.vue',
  'pages/users/[id].vue',
  'pages/users.vue',
  'pages/[...slug].vue',
], { roots: ['pages/'] })

const routes = toVueRouter4(tree)
// [
//   { name: 'index', path: '/', file: 'pages/index.vue', children: [] },
//   { name: 'about', path: '/about', file: 'pages/about.vue', children: [] },
//   { name: 'users', path: '/users', file: 'pages/users.vue', children: [
//     { name: 'users-id', path: ':id()', file: 'pages/users/[id].vue', children: [] },
//   ]},
//   { name: 'slug', path: '/:slug(.*)*', file: 'pages/[...slug].vue', children: [] },
// ]

Nuxt-like usage with layers

import { buildTree, toVueRouter4 } from 'unrouting'

// Files from app + layer directories with priority
const tree = buildTree([
  { path: 'pages/index.vue', priority: 0 }, // app layer (wins on collision)
  { path: 'pages/dashboard.vue', priority: 0 },
  { path: 'pages/dashboard/settings.vue', priority: 0 },
  { path: 'layer/pages/dashboard/analytics.vue', priority: 1 }, // extending layer
  { path: 'layer/pages/index.vue', priority: 1 }, // overridden by app layer
], {
  roots: ['pages/', 'layer/pages/'],
  extensions: ['.vue'],
  modes: ['client', 'server'],
  warn: msg => console.warn(msg),
})

const routes = toVueRouter4(tree, {
  onDuplicateRouteName: (name, file, existingFile) => {
    console.warn(`Duplicate route name "${name}": ${file} and ${existingFile}`)
  },
})

Emitting to different formats

All emitters accept a RouteTree:

import { buildTree, toRegExp, toRou3, toVueRouter4 } from 'unrouting'

const tree = buildTree(['users/[id]/posts/[slug].vue'])

// Vue Router 4 – nested routes with names, files, children
const vueRoutes = toVueRouter4(tree)
// [{ name: 'users-id-posts-slug', path: '/users/:id()/posts/:slug()', file: '...', children: [] }]

// rou3/Nitro – flat route patterns
const rou3Routes = toRou3(tree)
// [{ path: '/users/:id/posts/:slug', file: '...' }]

// RegExp – matcher patterns with named groups
const regexpRoutes = toRegExp(tree)
// [{ pattern: /^\/users\/(?<id>[^/]+)\/posts\/(?<slug>[^/]+)\/?$/, keys: ['id', 'slug'], file: '...' }]

Incremental updates (dev server)

The route tree is mutable. Instead of rebuilding everything when a file changes, use addFile and removeFile to update the tree in place – avoiding the cost of re-parsing all files and reconstructing the tree from scratch on every change.

import { addFile, buildTree, removeFile, toVueRouter4 } from 'unrouting'

const opts = { roots: ['pages/'], extensions: ['.vue'] }

// Build once at startup
const tree = buildTree(initialFiles, opts)
let routes = toVueRouter4(tree)

// On file add/remove (e.g., from a watcher callback)
addFile(tree, 'pages/new-page.vue', opts)
routes = toVueRouter4(tree)

removeFile(tree, 'pages/old-page.vue')
routes = toVueRouter4(tree)

// Rename = remove + add
removeFile(tree, 'pages/old-name.vue')
addFile(tree, 'pages/new-name.vue', opts)
routes = toVueRouter4(tree)

addFile supports the same InputFile format as buildTree for layer priority:

addFile(tree, { path: 'layer/pages/about.vue', priority: 1 }, opts)

Standalone parsing and segment conversion

If you don't need the full tree pipeline – e.g., you already have resolved routes and only need to convert individual path segments or strings to Vue Router syntax – you can use the parse + convert functions directly:

import { parsePath, parseSegment, toVueRouterPath, toVueRouterSegment } from 'unrouting'

// Parse a full file path
const [result] = parsePath(['users/[id]/profile.vue'])
// {
//   file: 'users/[id]/profile.vue',
//   segments: [
//     [{ type: 'static', value: 'users' }],
//     [{ type: 'dynamic', value: 'id' }],
//     [{ type: 'static', value: 'profile' }],
//   ],
// }

// Convert parsed segments to a Vue Router path
toVueRouterPath(result.segments) // => '/users/:id()/profile'

// Parse and convert a single segment (e.g., i18n per-locale route path)
const tokens = parseSegment('[...slug]')
// [{ type: 'catchall', value: 'slug' }]
toVueRouterSegment(tokens) // => ':slug(.*)*'

Supported patterns

Pattern Example Description
Static about.vue Static route segment
Index index.vue Index page (maps to /)
Dynamic [slug].vue Required parameter
Optional [[slug]].vue Optional parameter
Catchall [...slug].vue Catch-all (zero or more segments)
Repeatable [slug]+.vue One or more segments
Optional repeatable [[slug]]+.vue Zero or more segments
Group (admin)/dashboard.vue Route group (transparent to path, stored in meta)
Mixed prefix-[slug]-suffix.vue Static and dynamic in one segment
Nested parent.vue + parent/child.vue Parent layout with child routes
Named views index@sidebar.vue Vue Router named view slots
Modes page.client.vue Mode variants (configurable suffixes)

API

buildTree(input, options?)

Build a route tree from file paths. Accepts raw strings, InputFile[] (with priority), or pre-parsed ParsedPath[].

function buildTree(
  input: string[] | InputFile[] | ParsedPath[],
  options?: BuildTreeOptions
): RouteTree

interface InputFile {
  path: string
  /** Lower number = higher priority. Default: 0 */
  priority?: number
}

Options (extends ParsePathOptions):

Option Type Description
roots string[] Root paths to strip (e.g., ['pages/', 'layer/pages/'])
extensions string[] File extensions to strip (default: strip all)
modes string[] Mode suffixes to detect (e.g., ['client', 'server'])
warn (msg: string) => void Warning callback for invalid characters in dynamic params
duplicateStrategy 'first-wins' | 'last-wins' | 'error' How to handle duplicate paths (default: 'first-wins')

When files from different layers collide at the same tree position, the file with the lowest priority number wins regardless of insertion order.

addFile(tree, filePath, options?)

Add a single file to an existing route tree in place. Parses the file and inserts it, avoiding a full rebuild. Accepts a plain string or InputFile with priority.

function addFile(
  tree: RouteTree,
  filePath: string | InputFile,
  options?: BuildTreeOptions
): void

removeFile(tree, filePath)

Remove a file from an existing route tree by its original file path. Prunes empty structural nodes left behind. Returns true if the file was found and removed.

function removeFile(tree: RouteTree, filePath: string): boolean

toVueRouter4(tree, options?)

Emit Vue Router 4 route definitions from a tree. Handles nested routes, names, index promotion, structural collapse, groups, catchall optimisation, route ordering, named views, and mode variants.

function toVueRouter4(tree: RouteTree, options?: VueRouterEmitOptions): VueRoute[]

interface VueRoute {
  name?: string
  path: string
  file?: string
  /** Named view components. Only present when multiple views exist. */
  components?: Record<string, string>
  /** Mode variants. Only present when mode files exist. */
  modes?: string[]
  children: VueRoute[]
  meta?: Record<string, unknown>
}

interface VueRouterEmitOptions {
  /** Custom name generator. Receives raw `/`-separated name, returns final name. */
  getRouteName?: (rawName: string) => string
  /** Called when two routes produce the same name. */
  onDuplicateRouteName?: (name: string, file: string, existingFile: string) => void
}

Routes are sorted by segment priority within each level: static segments first, then dynamic, optional, and catchall last.

toRou3(tree)

Emit rou3/Nitro route patterns from a tree.

function toRou3(tree: RouteTree): Rou3Route[]

interface Rou3Route {
  path: string
  file: string
}

vueRouterToRou3(path, options?)

Convert a compiled Vue Router path string (e.g. a route definition's path) into one or more rou3 patterns. Useful when you already have resolved Vue Router routes (not files) and need to feed them to rou3/Nitro, for example when a module rewrites paths at runtime.

function vueRouterToRou3(path: string, options?: VueRouterToRou3Options): VueRouterToRou3Result

interface VueRouterToRou3Options {
  /** Expand finite alternation params (`:locale(de|fr)`) into concrete paths. @default true */
  expand?: boolean
  /** Max paths a single input may expand to before falling back to a dynamic param. Must be a positive integer or a TypeError is thrown. @default 100 */
  maxExpansions?: number
  /** Collapse each result into a glob using only `*` and `**` wildcards. @default false */
  collapse?: boolean
}

interface VueRouterToRou3Result {
  /** The converted rou3 patterns. */
  patterns: string[]
  /** One entry per lossy or widening conversion step; empty when the conversion is faithful. */
  issues: VueRouterToRou3Issue[]
}

Params carrying a finite alternation regexp are expanded into concrete paths; other custom regexps are preserved as rou3 param constraints where rou3 enforces them (plain and optional params). They are dropped on repeatable params (rou3 ignores them there) and when the regexp contains / (rou3 splits patterns on slashes and cannot represent it).

import { vueRouterToRou3 } from 'unrouting'

vueRouterToRou3('/:locale(de|fr)/account/verify').patterns
// => ['/de/account/verify', '/fr/account/verify']

vueRouterToRou3('/users/:id(\\d+)').patterns
// => ['/users/:id(\\d+)']

vueRouterToRou3('/:pathMatch(.*)*').patterns
// => ['/:pathMatch*']

With collapse: true, each path becomes a glob built from * and ** wildcards. This exists because some targets (route-rule keys, hosting provider config exports) only understand static prefixes and wildcard globs, not :param matchers. A bare whole-segment param (:id) maps exactly to a single-segment * wildcard, so no issue is reported and later segments stay intact. A bare optional param (:id?) becomes a trailing * (optional in rou3) or, mid-path, expands into both variants (with and without the segment). Anything a wildcard cannot represent exactly (a regexp constraint, a partial segment like prefix-:id, a repeatable param) collapses into a ** catch-all from that segment onwards, with a collapsed issue. Enumerable params are still expanded first, so the glob stays as narrow as possible.

vueRouterToRou3('/products/:id/edit', { collapse: true }).patterns
// => ['/products/*/edit']

vueRouterToRou3('/users/:id(\\d+)/edit', { collapse: true }).patterns
// => ['/users/**']

vueRouterToRou3('/:locale(de|fr)/account/:id', { collapse: true }).patterns
// => ['/de/account/*', '/fr/account/*']

vueRouterToRou3('/static/path', { collapse: true }).patterns
// => ['/static/path']

Conversions are best-effort: a collapsed catch-all matches more than the original path, repeatable params lose their regexp constraint (rou3 does not enforce them) and huge alternations fall back to plain dynamic params. Every such step is recorded in issues so callers can surface them to their users:

const { patterns, issues } = vueRouterToRou3('/products/:id(\\d+)', { collapse: true })
// patterns => ['/products/**']
// issues => [{ type: 'collapsed', message: 'Collapsed "/products/:id(\\d+)" at segment ":id(\\d+)" into a `**` catch-all, ...' }]

toRegExp(tree)

Emit RegExp matchers from a tree.

function toRegExp(tree: RouteTree): RegExpRoute[]

interface RegExpRoute {
  pattern: RegExp
  keys: string[]
  file: string
}

rou3PatternToURLPattern(pattern, options?)

Convert a single rou3/Nitro route pattern into a URLPattern pathname pattern (e.g. for a Speculation Rules href_matches rule, or new URLPattern({ pathname })). rou3 describes its syntax as URLPattern-compatible, so most tokens (:name, :name(regex), :name?, :name+, (regex), {...}) pass through unchanged. Only * and ** genuinely differ and are translated: rou3 * (single segment) becomes ([^/]*), and rou3 **/**:name (catch-all) becomes *.

function rou3PatternToURLPattern(pattern: string, options?: { segment?: 'strict' | 'loose' }): {
  pattern: string
  issues: Rou3ToURLPatternIssue[]
}

rou3PatternToURLPattern('/blog/**').pattern // => '/blog/*'
rou3PatternToURLPattern('/users/:id').pattern // => '/users/:id'
rou3PatternToURLPattern('/users/:id(\\d+)').pattern // => '/users/:id(\\d+)'

Pass { segment: 'loose' } to collapse single-segment tokens (:name, *) to * instead, matching Nuxt's historical inline conversion. This over-matches (a single * matches across /), so each collapse is recorded in issues:

const { pattern, issues } = rou3PatternToURLPattern('/users/:id', { segment: 'loose' })
// pattern => '/users/*'
// issues => [{ type: 'widened', param: 'id', message: 'Widened ":id" ...' }]

toVueRouterSegment(tokens, options?)

Convert a single parsed segment (an array of tokens returned by parseSegment) into a Vue Router 4 path segment string. Useful for modules that already have resolved routes and only need segment-level path conversion (e.g., @nuxtjs/i18n converting per-locale custom paths).

function toVueRouterSegment(
  tokens: ParsedPathSegmentToken[],
  options?: ToVueRouterSegmentOptions
): string

interface ToVueRouterSegmentOptions {
  /**
   * Whether non-index segments follow this one.
   * When true, catchall uses ([^/]*)*; when false (default), uses (.*)*
   */
  hasSucceeding?: boolean
}
import { parseSegment, toVueRouterSegment } from 'unrouting'

toVueRouterSegment(parseSegment('[id]')) // => ':id()'
toVueRouterSegment(parseSegment('[[opt]]')) // => ':opt?'
toVueRouterSegment(parseSegment('[...slug]')) // => ':slug(.*)*'
toVueRouterSegment(parseSegment('prefix-[slug]')) // => 'prefix-:slug()'

// i18n use case – parse a custom locale path segment
const tokens = parseSegment('[foo]_[bar]:[...buz]_buz_[[qux]]')
const path = `/${toVueRouterSegment(tokens)}`
// => '/:foo()_:bar()\::buz(.*)*_buz_:qux?'

toVueRouterPath(segments)

Convert an array of parsed path segments into a full Vue Router 4 path string. Automatically determines hasSucceeding per segment so that mid-path catchalls use the restrictive ([^/]*)* pattern and terminal catchalls use (.*)*.

function toVueRouterPath(segments: ParsedPathSegment[]): string
import { parsePath, toVueRouterPath } from 'unrouting'

toVueRouterPath(parsePath(['users/[id]/posts.vue'])[0].segments)
// => '/users/:id()/posts'

toVueRouterPath(parsePath(['[...slug]/suffix.vue'])[0].segments)
// => '/:slug([^/]*)*/suffix'  (mid-path catchall auto-detected)

toVueRouterPath(parsePath(['prefix/[...slug].vue'])[0].segments)
// => '/prefix/:slug(.*)*'     (terminal catchall)

parsePath(filePaths, options?)

Parse file paths into segments. Standalone – does not build a tree.

function parsePath(filePaths: string[], options?: ParsePathOptions): ParsedPath[]

interface ParsedPath {
  file: string
  segments: ParsedPathSegment[]
  meta?: { modes?: string[], name?: string }
}

compileParsePath(options?)

Pre-compile parsing options into a reusable function. Useful in hot paths (e.g., dev server file watchers) where parsePath would otherwise reconstruct the same regexes on every call.

function compileParsePath(options?: ParsePathOptions): CompiledParsePath

interface CompiledParsePath {
  (filePaths: string[]): ParsedPath[]
}

Returns a callable with the same signature as parsePath (minus the options argument). The regexes for root stripping, extension matching, and mode detection are built once at compile time.

import { addFile, buildTree, compileParsePath, toVueRouter4 } from 'unrouting'

const opts = { roots: ['pages/'], modes: ['client', 'server'] }

// Compile once at startup
const parse = compileParsePath(opts)
const tree = buildTree(initialFiles, opts)

// In a file watcher callback — no regex re-compilation
addFile(tree, 'pages/new-page.vue', parse)
const routes = toVueRouter4(tree)

The compiled function can be passed directly to addFile as the options argument:

// These are equivalent, but the compiled version avoids re-building regexes:
addFile(tree, file, parse) // pre-compiled (fast)
addFile(tree, file, opts) // raw options (re-compiles each call)

parseSegment(segment, absolutePath?, warn?)

Parse a single filesystem segment into typed tokens. Useful for modules that need to parse custom paths (e.g., i18n locale-specific routes).

function parseSegment(
  segment: string,
  absolutePath?: string,
  warn?: (message: string) => void
): ParsedPathSegmentToken[]

// Token types: 'static' | 'dynamic' | 'optional' | 'catchall' |
//              'repeatable' | 'optional-repeatable' | 'group'

walkTree(tree, visitor)

Walk all nodes depth-first.

function walkTree(
  tree: RouteTree,
  visitor: (node: RouteNode, depth: number, parent: RouteNode | null) => void
): void

isPageNode(node)

Check if a node has files attached (page node vs structural node).

function isPageNode(node: RouteNode): boolean

How nesting works

The tree distinguishes between page nodes (have files) and structural nodes (directory-only, no files):

  • Page nodes create nesting boundaries – children get relative paths
  • Structural nodes collapse – their path segment is prepended to descendants
parent.vue + parent/child.vue
  → { path: '/parent', children: [{ path: 'child' }] }

parent/child.vue  (no parent.vue)
  → { path: '/parent/child' }  (structural 'parent' collapses)

index.vue promotes a structural directory into a page node:

users/index.vue + users/[id].vue
  → { path: '/users', file: 'users/index.vue', children: [{ path: ':id()' }] }

Route groups (name) are transparent – they don't affect paths or nesting, but are stored in meta.groups.

Development

  • Clone this repository
  • Enable Corepack using corepack enable
  • Install dependencies using pnpm install
  • Run interactive tests using pnpm dev

License

Made with ❤️

Published under MIT License.

About

Making filesystem routing universal

Resources

Code of conduct

Stars

193 stars

Watchers

1 watching

Forks

Releases

Sponsor this project

Used by

Contributors

Languages