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 /productssrc/routes/products/post.ts โ POST /productssrc/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.
HEAD and OPTIONS: automatic, no boilerplate#
Tina4 handles HEAD and OPTIONS for you. You don't register them. They follow from your router.get(...) / router.post(...) / etc. routes:
HEADis identical toGETexcept the response carries no body (RFC 9110 ยง9.3.2). EveryGETroute automatically responds toHEAD: the framework strips the response body on the way out and preservesContent-Lengthpointing at the byte count the equivalentGETwould have sent. Cache validators, link checkers, and uptime probes work without you writing anything.OPTIONSon any registered path returns204 No Contentwith anAllow:header listing every method the path supports (RFC 9110 ยง9.3.7).- Wrong method on an existing path returns
405 Method Not Allowedwith the sameAllow:header (RFC 9110 ยง15.5.6 + ยง10.2.1). SendingPUTto aGET-only route used to return404, which was semantically wrong and confusing for link checkers. Now you get a real405. TRACEandCONNECTare rejected with405(security default for origin servers).
# HEAD on a GET route - same headers, empty bodycurl -sI http://localhost:7148/products# HTTP/1.1 200 OK# Content-Type: application/json# Content-Length: 33โ# OPTIONS - discover what the path supportscurl -sI -X OPTIONS http://localhost:7148/products# HTTP/1.1 204 No Content# Allow: GET, POST, HEAD, OPTIONSโ# Wrong method - clear 405 with Allow headercurl -sI -X PUT http://localhost:7148/products# HTTP/1.1 405 Method Not Allowed# Allow: GET, POST, HEAD, OPTIONSExplicit router.head() and router.options() registration#
The automatic behaviour is enough for 95% of apps. When you need custom HEAD or OPTIONS handlers, register them explicitly:
import { Router } from "@tina4/core";const router = new Router();โ// HEAD handler that doesn't run the full GET body - useful for// expensive endpoints where the client only needs to check existence// or read validators (ETag, Last-Modified).router.head("/expensive/{id}", async (req, res) => { res.raw.setHeader("ETag", computeEtag(req.params.id)); return res({});});โ// OPTIONS handler that returns more than just Allow - for example// a discovery endpoint.router.options("/api/discovery", async (_req, res) => { return res({ version: "v1", endpoints: [] });});The framework still strips the response body for HEAD handlers (RFC 9110 ยง9.3.2 is a MUST), so you can't accidentally leak content even if your handler returns a body.
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.
Auto-casting: Tina4 automatically casts path parameter values that are purely numeric to numbers. For example, requesting
/users/42/posts/99will give youreq.params.idas the number42andreq.params.postIdas the number99-- no explicit:inttype hint required. The:inttype hint adds validation (rejecting non-numeric values with a 404), but the auto-casting happens regardless.
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 numberRouter.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 numberRouter.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 stringRouter.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 numbercurl http://localhost:7148/products/42{"product_id":42,"type":"number"}# Integer route -- non-integer gives a 404curl 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", (group) => {โ group.get("/users", async (req, res) => { return res.json({ users: [] }); });โ group.get("/users/{id:int}", async (req, res) => { const id = req.params.id; return res.json({ user: { id, name: "Alice" } }); });โ group.post("/users", async (req, res) => { return res.status(201).json({ created: true }); });โ group.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", (api) => { api.group("/v1", (v1) => { v1.get("/status", async (req, res) => { return res.json({ version: "1.0" }); }); });โ api.group("/v2", (v2) => { v2.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";โfunction logRequest(req, res, next) { const start = Date.now(); console.log(`[${new Date().toISOString()}] ${req.method} ${req.path}`); next(); const duration = Date.now() - start; console.log(` Completed in ${duration}ms`);}โ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() to continue to the next middleware or 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";โfunction requireApiKey(req, res, next) { const apiKey = req.headers["x-api-key"] ?? "";โ if (apiKey !== "my-secret-key") { res({ error: "Invalid API key" }, 401); return; }โ next();}โ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", (group) => {โ group.get("/dashboard", async (req, res) => { return res.json({ page: "admin dashboard" }); });โ group.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 } 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 - publicGET /products - publicPOST /products - securedPUT /products/{id} - securedPATCH /products/{id} - securedDELETE /products/{id} - securedGET /api/v1/users - publicGET /api/v1/users/{id:int} - publicPOST /api/v1/users - securedGET /api/admin/dashboard requireAuth publicGET /api/admin/users requireAuth publicGET /api/public/info - @noauthGET /api/profile - @securedGET /search - publicGET /docs/* - publicFilter by method or search for a path pattern:
tina4 routes --method POSTtina4 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 allcurl http://localhost:7148/api/productsโ# Filter by categorycurl "http://localhost:7148/api/products?category=Fitness"โ# Get onecurl http://localhost:7148/api/products/3โ# Createcurl -X POST http://localhost:7148/api/products \ -H "Content-Type: application/json" \ -d '{"name": "Desk Lamp", "category": "Office", "price": 39.99, "inStock": true}'โ# Updatecurl -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}'โ# Deletecurl -X DELETE http://localhost:7148/api/products/3โ# Not foundcurl 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 categoryRouter.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 IDRouter.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 productRouter.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 productRouter.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 productRouter.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 Must Be Passed as Function References in an Array#
Problem: Passing a middleware function name as a string causes an error.
Cause: Tina4 expects middleware as an array of function references, not as strings.
Fix: Pass middleware as an array of function references: [myMiddleware], not "myMiddleware".
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", ...).