Routing System

File Conventions

Master TanStack Router-style file conventions for REST APIs. Learn flat routes, nested folders, path params ($id), catch-alls ($), and layouts.

Taser.js adopts modern TanStack Router-style file routing conventions tailored specifically for REST APIs. Your filesystem serves as the single source of truth for URL endpoints, HTTP methods, and cascading middleware pipelines.


Route Files vs Layout Files

Under your src/routes/ directory, files are classified into two distinct types:

  1. Route Files: Filenames containing an HTTP verb before .ts (such as users.get.ts, posts.post.ts, items.$id.delete.ts, tasks/$id.complete.patch.ts). Route files export a default route definition created with t.get(), t.post(), etc.
  2. Layout Files: Filenames ending in .ts without an HTTP verb (such as $.ts, admin.ts, _auth.ts, tasks/$id.ts). Layout files export a default middleware pipeline created with t.layout().

Filesystem Path vs. Builder Path String

The filesystem location is the sole runtime source of truth for URL endpoints and cascading middleware trees. The path string in t.get("/users") or t.layout("/admin/*") exists purely for TypeScript compile-time type inference (populating ctx.params) and compile-time validation against the project's ambient RoutePath union.

Source of Truth Comparison

AspectFilesystem Path (src/routes/...)Builder String (t.get("/path"), t.layout("/*"))
URL DeterminationSole source of truth for URL matchingNo runtime impact on request routing
AST Scan ValidationEnforces valid file conventions and factory verb parityScanned for factory name (t.get, t.layout), not path identity
TypeScript TypecheckEmits ambient RoutePath / LayoutId typesValidated by tsc against ambient RoutePath; types ctx.params
Mismatch ConsequenceDetermines where requests actually routetsc compile error if invalid; param type drift if matching another route

Route Path String Mismatch Error

If the path string in a route builder does not match any valid route path in the emitted ambient union, TypeScript flags the mismatch during tsc:

Type '"/usr/profile"' is not assignable to type 'RoutePath'.

HTTP Method Suffixes

Taser.js determines the HTTP verb from the final extension segment before .ts:

File NameHTTP MethodEndpoint URL
src/routes/users.get.tsGET/users
src/routes/users.post.tsPOST/users
src/routes/users.put.tsPUT/users
src/routes/users.patch.tsPATCH/users
src/routes/users.delete.tsDELETE/users
src/routes/users.options.tsOPTIONS/users
src/routes/users.head.tsHEAD/users
src/routes/users.query.tsQUERY/users
src/routes/users.any.tsANY (multi-method)/users
src/routes/users.all.tsALL (catch-all verb)/users

Flat Routes, Directory Routes, and Mixed Notation

Taser.js supports nested directory structures, flat dot notation, and mixed folder + dot notation. You can use whichever structure keeps your codebase cleanest:

GETindex.get.ts
/posts
GET$id.get.ts
/posts/:id
PUTedit.put.ts
/posts/:id/edit
PATCHcomplete.patch.ts
/tasks/:id/complete

Dot Separators in Filenames

A dot . inside a filename (e.g. tasks/$id.complete.patch.ts or posts.$id.edit.get.ts) acts as a URL segment separator (/), allowing you to define sub-routes flatly without creating deeply nested folders.


Index Routes (index.<method>.ts)

An index segment targets the root of its parent route without appending /index to the public URL:

GETindex.get.ts
/
GETapi.index.get.ts
/api
GETindex.get.ts
/products
POSTindex.post.ts
/products
GETposts.$id.index.get.ts
/posts/:id

Index Routes vs. Dynamic Parameter Routes ($param)

PatternNotationExample FileResolved URLRecommended Use Case
Direct Param RouteFlat or Directoryposts.$id.get.ts or posts/$id.get.tsGET /posts/:idStandard: Standalone endpoint for a single parameter leaf.
Directory Index Param RouteDirectoryposts/$id/index.get.tsGET /posts/:idGrouping: Co-locating root parameter handler with nested sub-routes (posts/$id/comments.get.ts, posts/$id/edit.put.ts).
Redundant Flat IndexFlat Dotposts.$id.index.get.tsGET /posts/:idDiscouraged: Redundant syntax in flat dot notation; prefer posts.$id.get.ts.

Collision & Duplicate Route Rules

Both posts.$id.get.ts and posts.$id.index.get.ts (as well as posts/$id/index.get.ts) evaluate to the exact same route: GET /posts/:id.

If both files exist simultaneously within the same routes directory, Taser.js halts the build immediately during the route scan phase with a ScanErrorCollection:

ScanError: Duplicate route for GET /posts/:id (posts.$id.get.ts and posts.$id.index.get.ts)

Route collisions are never silently overwritten and do not result in last-write-wins or undefined behavior. You must remove one of the duplicate route files to resolve the build error.


Dynamic Path Parameters ($param)

Prefixing a segment with $ denotes a named URL path parameter:

GETusers.$id.get.ts
/users/:id
PATCH$id.complete.patch.ts
/tasks/:id/complete
GETorgs.$orgId.repos.$repoId.get.ts
/orgs/:orgId/repos/:repoId

Inside your route handler, parameters are automatically typed and validated on ctx.params:

src/routes/tasks/$id.complete.patch.ts
import { json } from "@taserjs/router/reply";
import { z } from "zod";
import { t } from "@taserjs/router";

export default t
  .patch("/tasks/:id/complete")
  .params(z.object({ id: z.string().uuid() }))
  .handler((ctx) => {
    // ctx.params.id is strictly typed as a UUID string
    return json({ taskId: ctx.params.id, completed: true });
  });

Layout Inheritance Note

In the example above, src/routes/tasks/$id.complete.patch.ts is the standard layout-inheriting route for PATCH /tasks/:id/complete. If a layout file src/routes/tasks/$id.ts exists, this route automatically inherits that layout's middleware chain ($.tstasks/$id.ts). See Layout Breakout Routes for the alternative syntax that skips this layout.


Wildcard Catch-All Splats ($)

A standalone $ segment captures wildcard rest segments (splats):

GET$.get.ts
/*
GETfiles.$.get.ts
/files/*
GET$.get.ts
/files/*

In your handler, the wildcard path matches all trailing sub-paths.


Root Layout Middleware ($.ts)

A top-level file named src/routes/$.ts serves as the Root Layout Middleware. It runs before every route in your application:

src/routes/$.ts
import { bodyLimit } from "@taserjs/router/body-limit";
import { secureHeaders } from "@taserjs/router/secure-headers";
import { t } from "@taserjs/router";

export default t
  .layout("/*")
  .use(secureHeaders())
  .use(bodyLimit({ maxSize: 1_000_000 }));

Pathless Layouts (Leading Underscore _layout)

A segment starting with an underscore _ (like _auth.ts, _auth/, or _auth.login.post.ts) is pathless. It applies scoped middleware to child routes without adding any segment to the public URL:

LAYOUT_auth.ts
/* (scoped _auth)
GETdashboard.get.ts
/dashboard
GETprofile.get.ts
/profile
POSTlogin.post.ts
/login

In flat dot notation:

GET_auth.dashboard.get.ts
/dashboard
GET_auth.profile.get.ts
/profile
GET_app._dashboard.metrics.get.ts
/metrics

Notice that _auth, _app, and _dashboard are omitted from the public URL.


Layout Breakout Routes (Trailing Underscore segment_)

A segment ending with an underscore _ is a breakout route (un-nested route). It targets the expected URL path, but breaks out of the parent layout hierarchy so it skips that segment's layout middleware.

The Problem Breakout Routes Solve

Suppose you have an authenticated /posts layout in src/routes/posts.ts (or a /tasks/:id layout in src/routes/tasks/$id.ts) that enforces user login or permission checks. You want a specific child endpoint (like /posts/:id/preview or /tasks/:id/complete) to skip that layout's checks.

By adding a trailing underscore to posts_ or $id_, the route keeps the URL segment but skips the layout:

ROOT$.ts
/*
LAYOUTposts.ts
/posts/*
GETindex.get.ts
/posts
GET$id.get.ts
/posts/:id
BREAKOUTposts_.$id.preview.get.ts
/posts/:id/preview (skips posts.ts)
LAYOUT$id.ts
/tasks/:id/*
GET$id.status.get.ts
/tasks/:id/status
BREAKOUT$id_.complete.patch.ts
/tasks/:id/complete (skips $id.ts)

Breakout Route vs. Base Route Alternatives

The base route tasks/$id.complete.patch.ts and the breakout route tasks/$id_.complete.patch.ts resolve to the identical endpoint URL (PATCH /tasks/:id/complete). They are mutually exclusive architectural alternatives for the same URL:

Route ConfigurationFile LocationResolved EndpointMiddleware ChainPurpose
Standard Route (Inheriting)tasks/$id.complete.patch.tsPATCH /tasks/:id/complete$.tstasks/$id.tsRuns the $id.ts layout checks (e.g. verifying task ownership or existence).
Breakout Route (Skipping)tasks/$id_.complete.patch.tsPATCH /tasks/:id/complete$.ts onlyBypasses the tasks/$id.ts layout checks while retaining the identical URL structure.

Breakout Route Collision Rule

These two files cannot coexist in the same routes tree. If both tasks/$id.complete.patch.ts and tasks/$id_.complete.patch.ts are present simultaneously, Taser.js halts compilation with a build error during route scanning:

ScanError: Duplicate route for PATCH /tasks/:id/complete (tasks/$id.complete.patch.ts and tasks/$id_.complete.patch.ts)

Taser.js enforces strict uniqueness per [URL + HTTP Method]. Collisions always result in a build-time error, never a silent override or last-write-wins.

Underscore Convention Rules

  • Leading underscore (_auth): Pathless layout / group (adds middleware, removes segment from URL). - Trailing underscore (posts_ or $id_): Breakout route (keeps segment in URL, removes parent layout from middleware chain).

Escaping Special Characters ([...])

When you need a literal character that would otherwise trigger a router convention (such as a literal dot ., literal leading underscore _, or literal index), wrap the character in brackets [...]:

File NameResolved URLExplanation
src/routes/sitemap[.]xml.get.tsGET /sitemap.xmlEscaped dot [.] prevents splitting into /sitemap/xml
src/routes/docs/v1[.]0/api.get.tsGET /docs/v1.0/apiPreserves v1.0 as a single path segment
src/routes/[_]private.get.tsGET /_privateEscaped [_] prevents segment from being treated as a pathless layout
src/routes/tasks/task[_].get.tsGET /tasks/task_Escaped [_] prevents segment from being treated as a layout breakout
src/routes/items/[index].get.tsGET /items/indexEscaped [index] prevents trimming to /items

Ignored & Private Files (- Prefix)

To co-locate utility files, helper functions, schemas, or test fixtures directly alongside route files without generating routes, prefix the file or folder with a dash -:

IGNORED-types.ts
(not routed)
IGNORED-schema.ts
(not routed)
GETindex.get.ts
/users
GET$id.get.ts
/users/:id
IGNOREDutils.ts
(not routed)

Complete Conventions Reference

File Path in src/routes/HTTP MethodResolved API URLLayout InheritanceDescription
$.tsN/AGlobalRootRoot application layout
index.get.tsGET//*Root index route
posts.tsN/A/posts/*Scoped layout for posts
posts.index.get.tsGET/posts/*/postsFlat posts index
posts.$id.get.tsGET/posts/:id/*/postsFlat parameterized route
tasks/$id.complete.patch.tsPATCH/tasks/:id/complete/*/tasksMixed folder + dot nested route
posts_.$id.edit.get.tsGET/posts/:id/edit/*Breakout route: skips posts.ts layout
tasks/$id_.complete.patch.tsPATCH/tasks/:id/complete/*/tasksBreakout route: skips tasks/$id.ts layout
files.$.get.tsGET/files/*/*Wildcard splat handler
_auth.tsN/AScoped/*Pathless auth layout
_auth.settings.get.tsGET/settings/*/_authPathless scoped route
sitemap[.]xml.get.tsGET/sitemap.xml/*Bracket-escaped literal dot
-helpers.tsN/AN/AN/AIgnored file

Next Steps