Tina4

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:

typescript
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:

json
{"message":"Hello, World!"}

The same thing using file-based routing. Create src/routes/hello/get.ts:

typescript
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:

typescript
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:

bash
curl http://localhost:7148/products
json
{"action":"list all products"}
bash
curl -X POST http://localhost:7148/products \  -H "Content-Type: application/json" \  -d '{"name": "Widget"}'
json
{"action":"create a product"}
bash
curl -X PUT http://localhost:7148/products/42
json
{"action":"replace product 42"}
bash
curl -X PATCH http://localhost:7148/products/42
json
{"action":"update product 42"}
bash
curl -X DELETE http://localhost:7148/products/42
json
{"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:

  • HEAD is identical to GET except the response carries no body (RFC 9110 ยง9.3.2). Every GET route automatically responds to HEAD: the framework strips the response body on the way out and preserves Content-Length pointing at the byte count the equivalent GET would have sent. Cache validators, link checkers, and uptime probes work without you writing anything.
  • OPTIONS on any registered path returns 204 No Content with an Allow: header listing every method the path supports (RFC 9110 ยง9.3.7).
  • Wrong method on an existing path returns 405 Method Not Allowed with the same Allow: header (RFC 9110 ยง15.5.6 + ยง10.2.1). Sending PUT to a GET-only route used to return 404, which was semantically wrong and confusing for link checkers. Now you get a real 405.
  • TRACE and CONNECT are rejected with 405 (security default for origin servers).
bash
# 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, OPTIONS

Explicit 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:

typescript
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:

typescript
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.ts
bash
curl http://localhost:7148/users/5/posts/99
json
{"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/99 will give you req.params.id as the number 42 and req.params.postId as the number 99 -- no explicit :int type hint required. The :int type 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:

typescript
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    });});
bash
curl http://localhost:7148/orders/42
json
{"order_id":42,"type":"number"}

Pass a non-integer value and the route refuses to match. You get a 404:

bash
curl http://localhost:7148/orders/abc
json
{"error":"Not found","path":"/orders/abc","status":404}

Supported types:

TypeMatchesAuto-castExample
intDigits onlyNumber{id:int} matches 42 but not abc
floatDecimal numbersNumber{price:float} matches 19.99
pathAll remaining path segments (catch-all)String{slug:path} matches docs/api/auth
alphaLetters onlyString{slug:alpha} matches hello but not hello123
alphanumericLetters and digitsString{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:

typescript
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    });});
bash
# Integer route -- matches digits, returns a numbercurl http://localhost:7148/products/42
json
{"product_id":42,"type":"number"}
bash
# Integer route -- non-integer gives a 404curl http://localhost:7148/products/abc
json
{"error":"Not found","path":"/products/abc","status":404}
bash
# Path catch-all -- captures everything after /files/curl http://localhost:7148/files/images/photos/cat.jpg
json
{"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:

typescript
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    });});
bash
curl "http://localhost:7148/search?q=keyboard&page=2&limit=20"
json
{"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:

typescript
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.

bash
curl http://localhost:7148/api/v1/users
json
{"users":[]}

Groups nest:

typescript
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" });        });    });});
bash
curl http://localhost:7148/api/v1/status
json
{"version":"1.0"}
bash
curl http://localhost:7148/api/v2/status
json
{"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:

typescript
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:

typescript
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]);
bash
curl http://localhost:7148/api/secret
json
{"error":"Invalid API key"}
bash
curl http://localhost:7148/api/secret -H "X-API-Key: my-secret-key"
json
{"secret":"The answer is 42"}

Middleware on a Group#

Apply middleware to an entire group:

typescript
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:

typescript
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:

typescript
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:

typescript
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:

typescript
import { Router } from "tina4-nodejs";โ€‹Router.get("/api/account", async (req, res) => {    return res.json({ account: req.user });}).secure();
bash
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:

typescript
Router.get("/api/catalog", async (req, res) => {    // Expensive database query    return res.json({ products });}).cache();

Chaining Both#

Chain .secure() and .cache() together:

typescript
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:

typescript
import { Router } from "tina4-nodejs";โ€‹Router.get("/docs/*", async (req, res) => {    const path = req.params["*"] ?? "";    return res.json({        section: "docs",        path    });});
bash
curl http://localhost:7148/docs/getting-started
json
{"section":"docs","path":"getting-started"}
bash
curl http://localhost:7148/docs/api/authentication/jwt
json
{"section":"docs","path":"api/authentication/jwt"}

Catch-All Route (Custom 404)#

Register a catch-all to handle any unmatched URL:

typescript
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:

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:

bash
tina4 routes
Method   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/*                       -                   public

Filter by method or search for a path pattern:

bash
tina4 routes --method POSTtina4 routes --filter users

11. 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 routes

Pattern 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.ts

Both 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:

MethodPathDescription
GET/api/productsList all products. Support ?category= filter.
GET/api/products/{id:int}Get a single product by ID. Return 404 if not found.
POST/api/productsCreate 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:

typescript
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:

bash
# 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/999

13. Solution#

Create src/routes/product-api.ts:

typescript
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:

json
{"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:

json
{"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):

json
{"id":6,"name":"Desk Lamp","category":"Office","price":39.99,"inStock":true}

Not found (Status: 404 Not Found):

json
{"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", ...).