Chapter 2: Routing
1. How Routing Works in Tina4
Every web application maps URLs to code. A browser requests /products. The framework finds the handler for /products. Runs it. Sends back the result. That mapping is routing.
In Tina4 Node.js, you define routes in TypeScript files inside src/routes/. Every .ts file in that directory -- and its subdirectories -- is auto-loaded when the server starts. No registration files. No central config. Drop a file in. It works.
Tina4 supports two routing styles: explicit registration with the Router class, and file-based routing where the file path becomes the URL.
The simplest possible route using explicit registration:
import { Router } from "tina4-nodejs";
Router.get("/hello", async (req, res) => {
return res.json({ message: "Hello, World!" });
});Save that as src/routes/hello.ts, start the server with tina4 serve, and visit http://localhost:7148/hello:
{"message":"Hello, World!"}The same thing using file-based routing. Create src/routes/hello/get.ts:
export default async (req, res) => {
return res.json({ message: "Hello, World!" });
};Both approaches produce identical results. Use whichever you prefer -- or mix them.
2. HTTP Methods
Tina4 supports all five standard HTTP methods. Each one has a static method on the Router class:
import { Router } from "tina4-nodejs";
Router.get("/products", async (req, res) => {
return res.json({ action: "list all products" });
});
Router.post("/products", async (req, res) => {
return res.status(201).json({ action: "create a product" });
});
Router.put("/products/{id}", async (req, res) => {
const id = req.params.id;
return res.json({ action: `replace product ${id}` });
});
Router.patch("/products/{id}", async (req, res) => {
const id = req.params.id;
return res.json({ action: `update product ${id}` });
});
Router.delete("/products/{id}", async (req, res) => {
const id = req.params.id;
return res.json({ action: `delete product ${id}` });
});For file-based routing, the HTTP method is the filename:
src/routes/products/get.ts → GET /products
src/routes/products/post.ts → POST /products
src/routes/products/[id]/put.ts → PUT /products/{id}Test each one:
curl http://localhost:7148/products{"action":"list all products"}curl -X POST http://localhost:7148/products \
-H "Content-Type: application/json" \
-d '{"name": "Widget"}'{"action":"create a product"}curl -X PUT http://localhost:7148/products/42{"action":"replace product 42"}curl -X PATCH http://localhost:7148/products/42{"action":"update product 42"}curl -X DELETE http://localhost:7148/products/42{"action":"delete product 42"}GET reads. POST creates. PUT replaces. PATCH patches. DELETE removes. REST convention. Predictable API.
3. Path Parameters
Path parameters capture values from the URL. Wrap the parameter name in curly braces:
import { Router } from "tina4-nodejs";
Router.get("/users/{id}/posts/{postId}", async (req, res) => {
const userId = req.params.id;
const postId = req.params.postId;
return res.json({
user_id: userId,
post_id: postId
});
});For file-based routing, wrap the parameter name in brackets:
src/routes/users/[id]/posts/[postId]/get.tscurl http://localhost:7148/users/5/posts/99{"user_id":"5","post_id":"99"}Notice user_id came back as the string "5", not the integer 5. Path parameters are strings by default.
Typed Parameters
Enforce a type by adding a colon and the type after the parameter name:
import { Router } from "tina4-nodejs";
Router.get("/orders/{id:int}", async (req, res) => {
const id = req.params.id; // This is now a number
return res.json({
order_id: id,
type: typeof id
});
});curl http://localhost:7148/orders/42{"order_id":42,"type":"number"}Pass a non-integer value and the route refuses to match. You get a 404:
curl http://localhost:7148/orders/abc{"error":"Not found","path":"/orders/abc","status":404}Supported types:
| Type | Matches | Auto-cast | Example |
|---|---|---|---|
int | Digits only | Number | {id:int} matches 42 but not abc |
float | Decimal numbers | Number | {price:float} matches 19.99 |
path | All remaining path segments (catch-all) | String | {slug:path} matches docs/api/auth |
alpha | Letters only | String | {slug:alpha} matches hello but not hello123 |
alphanumeric | Letters and digits | String | {code:alphanumeric} matches abc123 |
The {name} form (no type) matches any single path segment and returns it as a string.
Typed Parameters in Action
Here is a complete example showing the most commonly used typed parameters together:
import { Router } from "tina4-nodejs";
// Integer parameter -- only digits match, auto-cast to number
Router.get("/products/{id:int}", async (req, res) => {
const id = req.params.id; // number, e.g. 42
return res.json({
product_id: id,
type: typeof id
});
});
// Float parameter -- decimal numbers, auto-cast to number
Router.get("/products/{id:int}/price/{price:float}", async (req, res) => {
const id = req.params.id;
const price = req.params.price;
return res.json({
product_id: id,
price,
type: typeof price
});
});
// Path parameter -- catch-all, captures remaining segments as a string
Router.get("/files/{filepath:path}", async (req, res) => {
const filepath = req.params.filepath;
// filepath could be "images/photos/cat.jpg"
return res.json({
filepath,
type: typeof filepath
});
});# Integer route -- matches digits, returns a number
curl http://localhost:7148/products/42{"product_id":42,"type":"number"}# Integer route -- non-integer gives a 404
curl http://localhost:7148/products/abc{"error":"Not found","path":"/products/abc","status":404}# Path catch-all -- captures everything after /files/
curl http://localhost:7148/files/images/photos/cat.jpg{"filepath":"images/photos/cat.jpg","type":"string"}The :int and :float types act as both a constraint and a converter. If the URL segment does not match the expected pattern, the route is skipped entirely and Tina4 moves on to the next registered route (or returns 404 if nothing matches). The :path type is greedy -- it consumes all remaining segments, making it ideal for file paths and documentation URLs.
4. Query Parameters
Query parameters are the key-value pairs after the ? in a URL. Access them via req.query:
import { Router } from "tina4-nodejs";
Router.get("/search", async (req, res) => {
const q = req.query.q ?? "";
const page = parseInt(req.query.page ?? "1", 10);
const limit = parseInt(req.query.limit ?? "10", 10);
return res.json({
query: q,
page,
limit,
offset: (page - 1) * limit
});
});curl "http://localhost:7148/search?q=keyboard&page=2&limit=20"{"query":"keyboard","page":2,"limit":20,"offset":20}A missing query parameter yields undefined from req.query.key. The nullish coalescing operator (??) provides defaults.
5. Route Groups
A set of routes sharing a common prefix belongs in a group. Router.group() eliminates repetition:
import { Router } from "tina4-nodejs";
Router.group("/api/v1", () => {
Router.get("/users", async (req, res) => {
return res.json({ users: [] });
});
Router.get("/users/{id:int}", async (req, res) => {
const id = req.params.id;
return res.json({ user: { id, name: "Alice" } });
});
Router.post("/users", async (req, res) => {
return res.status(201).json({ created: true });
});
Router.get("/products", async (req, res) => {
return res.json({ products: [] });
});
});The routes register as /api/v1/users, /api/v1/users/{id}, and /api/v1/products. Short paths inside the group. Tina4 prepends the prefix.
curl http://localhost:7148/api/v1/users{"users":[]}Groups nest:
import { Router } from "tina4-nodejs";
Router.group("/api", () => {
Router.group("/v1", () => {
Router.get("/status", async (req, res) => {
return res.json({ version: "1.0" });
});
});
Router.group("/v2", () => {
Router.get("/status", async (req, res) => {
return res.json({ version: "2.0" });
});
});
});curl http://localhost:7148/api/v1/status{"version":"1.0"}curl http://localhost:7148/api/v2/status{"version":"2.0"}6. Middleware on Routes
Middleware is code that runs before (or after) your route handler. Authentication. Logging. Rate limiting. Input validation. Anything that belongs on multiple routes.
Middleware on a Single Route
Pass middleware as the third argument to any route method:
import { Router } from "tina4-nodejs";
async function logRequest(req, res, next) {
const start = Date.now();
console.log(`[${new Date().toISOString()}] ${req.method} ${req.path}`);
const result = await next(req, res);
const duration = Date.now() - start;
console.log(` Completed in ${duration}ms`);
return result;
}
Router.get("/api/data", async (req, res) => {
return res.json({ data: [1, 2, 3] });
}, "logRequest");The middleware function receives req, res, and next. Call next(req, res) to continue to the route handler. Skip the call and the handler never runs -- the chain stops. A locked gate for unauthorized requests.
Blocking Middleware
Middleware that checks for an API key:
import { Router } from "tina4-nodejs";
async function requireApiKey(req, res, next) {
const apiKey = req.headers["x-api-key"] ?? "";
if (apiKey !== "my-secret-key") {
return res.status(401).json({ error: "Invalid API key" });
}
return next(req, res);
}
Router.get("/api/secret", async (req, res) => {
return res.json({ secret: "The answer is 42" });
}, "requireApiKey");curl http://localhost:7148/api/secret{"error":"Invalid API key"}curl http://localhost:7148/api/secret -H "X-API-Key: my-secret-key"{"secret":"The answer is 42"}Middleware on a Group
Apply middleware to an entire group:
import { Router } from "tina4-nodejs";
Router.group("/api/admin", () => {
Router.get("/dashboard", async (req, res) => {
return res.json({ page: "admin dashboard" });
});
Router.get("/users", async (req, res) => {
return res.json({ page: "user management" });
});
}, "requireAuth");Multiple Middleware
Chain multiple middleware by passing an array:
Router.get("/api/important", async (req, res) => {
return res.json({ data: "important stuff" });
}, ["logRequest", "requireApiKey", "requireAuth"]);Middleware runs in order: logRequest first, then requireApiKey, then requireAuth, then the route handler.
7. Route Decorators: @noauth and @secured
Tina4 provides two special decorators for controlling authentication on routes.
@noauth -- Public Routes
When your application has global authentication middleware, @noauth marks specific routes as public:
import { Router, noauth } from "tina4-nodejs";
/**
* @noauth
*/
Router.get("/api/public/info", async (req, res) => {
return res.json({
app: "My Store",
version: "1.0.0"
});
});The @noauth decorator tells Tina4 to skip authentication checks for this route, even with global auth middleware configured in .env.
@secured -- Protected GET Routes
The @secured annotation marks a GET route as requiring authentication:
import { Router } from "tina4-nodejs";
/**
* @secured
*/
Router.get("/api/profile", async (req, res) => {
return res.json({
user: req.user
});
});By default, POST, PUT, PATCH, and DELETE routes are considered secured. GET routes are not -- they are public unless you add @secured.
8. Route Chaining: .secure() and .cache()
Routes return a chainable object. Two methods you can call on any route: .secure() and .cache().
.secure()
.secure() requires a valid bearer token in the Authorization header. If the token is missing or invalid, the route returns 401 Unauthorized without ever reaching your handler:
import { Router } from "tina4-nodejs";
Router.get("/api/account", async (req, res) => {
return res.json({ account: req.user });
}).secure();curl http://localhost:7148/api/account
# 401 Unauthorized
curl http://localhost:7148/api/account -H "Authorization: Bearer eyJhbGci..."
# 200 OK.cache()
.cache() enables response caching for the route. Once the handler runs and produces a response, subsequent requests to the same URL return the cached result without re-executing the handler:
Router.get("/api/catalog", async (req, res) => {
// Expensive database query
return res.json({ products });
}).cache();Chaining Both
Chain .secure() and .cache() together:
Router.get("/api/data", async (req, res) => {
return res.json({ data });
}).secure().cache();This route requires a bearer token and caches the response. Order does not matter -- .cache().secure() produces the same result.
9. Wildcard and Catch-All Routes
Wildcard Routes
Use * at the end of a path to match anything after it:
import { Router } from "tina4-nodejs";
Router.get("/docs/*", async (req, res) => {
const path = req.params["*"] ?? "";
return res.json({
section: "docs",
path
});
});curl http://localhost:7148/docs/getting-started{"section":"docs","path":"getting-started"}curl http://localhost:7148/docs/api/authentication/jwt{"section":"docs","path":"api/authentication/jwt"}Catch-All Route (Custom 404)
Register a catch-all to handle any unmatched URL:
import { Router } from "tina4-nodejs";
Router.get("/*", async (req, res) => {
return res.status(404).json({
error: "Page not found",
path: req.path
});
});Define this route last. Tina4 matches routes in registration order -- first match wins.
You can also create a custom 404 page by placing a template at src/templates/errors/404.html:
{% extends "base.html" %}
{% block title %}Not Found{% endblock %}
{% block content %}
<h1>404 - Page Not Found</h1>
<p>The page you are looking for does not exist.</p>
<a href="/">Go back home</a>
{% endblock %}Tina4 uses this template for any unmatched route when the file exists.
10. Route Listing via CLI
As your application grows, you need visibility into all registered routes. The CLI provides it:
tina4 routesMethod Path Middleware Auth
------ ---- ---------- ----
GET /hello - public
GET /products - public
POST /products - secured
PUT /products/{id} - secured
PATCH /products/{id} - secured
DELETE /products/{id} - secured
GET /api/v1/users - public
GET /api/v1/users/{id:int} - public
POST /api/v1/users - secured
GET /api/admin/dashboard requireAuth public
GET /api/admin/users requireAuth public
GET /api/public/info - @noauth
GET /api/profile - @secured
GET /search - public
GET /docs/* - publicFilter by method or search for a path pattern:
tina4 routes --method POST
tina4 routes --filter users11. Organizing Route Files
Organize route files however you want. Tina4 loads every .ts file in src/routes/ recursively. Two common patterns:
Pattern 1: One File Per Resource
src/routes/
├── products.ts # All product routes
├── users.ts # All user routes
├── orders.ts # All order routes
└── pages.ts # HTML page routesPattern 2: File-Based Routing by Feature
src/routes/
├── api/
│ ├── products/
│ │ ├── get.ts # GET /api/products
│ │ ├── post.ts # POST /api/products
│ │ └── [id]/
│ │ ├── get.ts # GET /api/products/{id}
│ │ ├── put.ts # PUT /api/products/{id}
│ │ └── delete.ts # DELETE /api/products/{id}
│ └── users/
│ ├── get.ts
│ └── post.ts
├── admin/
│ ├── dashboard/get.ts
│ └── settings/get.ts
└── pages/
├── home/get.ts
└── about/get.tsBoth patterns work identically. Choose whichever keeps your project navigable.
12. Exercise: Build a Full CRUD API for Products
Build a complete REST API for managing products. All data lives in a TypeScript array (no database yet -- that comes in Chapter 5).
Requirements
Create a file src/routes/product-api.ts with the following routes:
| Method | Path | Description |
|---|---|---|
GET | /api/products | List all products. Support ?category= filter. |
GET | /api/products/{id:int} | Get a single product by ID. Return 404 if not found. |
POST | /api/products | Create a new product. Return 201. |
PUT | /api/products/{id:int} | Replace a product. Return 404 if not found. |
DELETE | /api/products/{id:int} | Delete a product. Return 204 with no body. |
Each product has: id (number), name (string), category (string), price (number), inStock (boolean).
Start with this seed data:
let products = [
{ id: 1, name: "Wireless Keyboard", category: "Electronics", price: 79.99, inStock: true },
{ id: 2, name: "Yoga Mat", category: "Fitness", price: 29.99, inStock: true },
{ id: 3, name: "Coffee Grinder", category: "Kitchen", price: 49.99, inStock: false },
{ id: 4, name: "Standing Desk", category: "Office", price: 549.99, inStock: true },
{ id: 5, name: "Running Shoes", category: "Fitness", price: 119.99, inStock: true }
];Test with:
# List all
curl http://localhost:7148/api/products
# Filter by category
curl "http://localhost:7148/api/products?category=Fitness"
# Get one
curl http://localhost:7148/api/products/3
# Create
curl -X POST http://localhost:7148/api/products \
-H "Content-Type: application/json" \
-d '{"name": "Desk Lamp", "category": "Office", "price": 39.99, "inStock": true}'
# Update
curl -X PUT http://localhost:7148/api/products/3 \
-H "Content-Type: application/json" \
-d '{"name": "Burr Coffee Grinder", "category": "Kitchen", "price": 59.99, "inStock": true}'
# Delete
curl -X DELETE http://localhost:7148/api/products/3
# Not found
curl http://localhost:7148/api/products/99913. Solution
Create src/routes/product-api.ts:
import { Router } from "tina4-nodejs";
// In-memory product store (resets on server restart)
let products = [
{ id: 1, name: "Wireless Keyboard", category: "Electronics", price: 79.99, inStock: true },
{ id: 2, name: "Yoga Mat", category: "Fitness", price: 29.99, inStock: true },
{ id: 3, name: "Coffee Grinder", category: "Kitchen", price: 49.99, inStock: false },
{ id: 4, name: "Standing Desk", category: "Office", price: 549.99, inStock: true },
{ id: 5, name: "Running Shoes", category: "Fitness", price: 119.99, inStock: true }
];
let nextId = 6;
// List all products, optionally filter by category
Router.get("/api/products", async (req, res) => {
const category = req.query.category ?? null;
if (category !== null) {
const filtered = products.filter(
p => p.category.toLowerCase() === String(category).toLowerCase()
);
return res.json({ products: filtered, count: filtered.length });
}
return res.json({ products, count: products.length });
});
// Get a single product by ID
Router.get("/api/products/{id:int}", async (req, res) => {
const id = req.params.id;
const product = products.find(p => p.id === id);
if (!product) {
return res.status(404).json({ error: "Product not found", id });
}
return res.json(product);
});
// Create a new product
Router.post("/api/products", async (req, res) => {
const body = req.body;
if (!body.name) {
return res.status(400).json({ error: "Name is required" });
}
const product = {
id: nextId++,
name: body.name,
category: body.category ?? "Uncategorized",
price: parseFloat(body.price ?? 0),
inStock: Boolean(body.inStock ?? true)
};
products.push(product);
return res.status(201).json(product);
});
// Replace a product
Router.put("/api/products/{id:int}", async (req, res) => {
const id = req.params.id;
const body = req.body;
const index = products.findIndex(p => p.id === id);
if (index === -1) {
return res.status(404).json({ error: "Product not found", id });
}
products[index] = {
id,
name: body.name ?? products[index].name,
category: body.category ?? products[index].category,
price: parseFloat(body.price ?? products[index].price),
inStock: Boolean(body.inStock ?? products[index].inStock)
};
return res.json(products[index]);
});
// Delete a product
Router.delete("/api/products/{id:int}", async (req, res) => {
const id = req.params.id;
const index = products.findIndex(p => p.id === id);
if (index === -1) {
return res.status(404).json({ error: "Product not found", id });
}
products.splice(index, 1);
return res.status(204).json(null);
});Expected output for the test commands:
List all:
{"products":[{"id":1,"name":"Wireless Keyboard","category":"Electronics","price":79.99,"inStock":true},{"id":2,"name":"Yoga Mat","category":"Fitness","price":29.99,"inStock":true},{"id":3,"name":"Coffee Grinder","category":"Kitchen","price":49.99,"inStock":false},{"id":4,"name":"Standing Desk","category":"Office","price":549.99,"inStock":true},{"id":5,"name":"Running Shoes","category":"Fitness","price":119.99,"inStock":true}],"count":5}Filter by category:
{"products":[{"id":2,"name":"Yoga Mat","category":"Fitness","price":29.99,"inStock":true},{"id":5,"name":"Running Shoes","category":"Fitness","price":119.99,"inStock":true}],"count":2}Create (Status: 201 Created):
{"id":6,"name":"Desk Lamp","category":"Office","price":39.99,"inStock":true}Not found (Status: 404 Not Found):
{"error":"Product not found","id":999}14. Gotchas
1. Trailing Slashes Matter
Problem: /products works but /products/ returns a 404 (or vice versa).
Cause: Tina4 treats /products and /products/ as different routes by default.
Fix: Pick one convention and stick with it. Set TINA4_TRAILING_SLASH_REDIRECT=true in .env and Tina4 redirects /products/ to /products.
2. Parameter Names Must Be Unique in a Path
Problem: /users/{id}/posts/{id} gives wrong results -- both parameters share the same name.
Cause: The second {id} overwrites the first in req.params.
Fix: Use distinct names: /users/{userId}/posts/{postId}.
3. Method Conflicts
Problem: You defined Router.get("/items/{id}", ...) and Router.get("/items/{action}", ...) and the wrong handler runs.
Cause: Both patterns match /items/42. The first one registered wins.
Fix: Use typed parameters to disambiguate: Router.get("/items/{id:int}", ...) matches integers only, leaving /items/export free for the other route.
4. Route Handler Must Return a Response
Problem: Your route handler runs but the browser shows an empty page or a 500 error.
Cause: You forgot the return statement. Without return, the handler produces undefined and Tina4 has nothing to send back.
Fix: Every handler must return res.json(...) or return res.html(...).
5. Async Handlers
Problem: Your handler calls an async function but the response returns before it completes.
Cause: You forgot to await the async operation. The handler returns before the work finishes.
Fix: await all async operations inside handlers. All Tina4 route handlers should be async functions.
6. Middleware Function Must Be a Named Function
Problem: Passing an anonymous arrow function as middleware causes an error.
Cause: Tina4 expects middleware referenced by function name (a string), not as an inline closure.
Fix: Define your middleware as a named function and pass the name as a string: "myMiddleware", not (req, res, next) => { ... }.
7. Group Prefix Must Start with a Slash
Problem: Router.group("api/v1", ...) produces routes that do not match.
Cause: The group prefix needs a leading /.
Fix: Start group prefixes with /: Router.group("/api/v1", ...).