Chapter 3: Request & Response#
1. The Two Objects You Always Get#
Every route handler in Tina4 receives two arguments: req and res. The request tells you what the client sent. The response is how you talk back. Together they are the entire HTTP conversation.
import { Router } from "tina4-nodejs";โRouter.get("/echo", async (req, res) => { return res.json({ method: req.method, path: req.path, your_ip: req.ip });});curl http://localhost:7148/echo{"method":"GET","path":"/echo","your_ip":"127.0.0.1"}The pattern for every route: inspect the request, build the response, return it.
2. The Request Object#
The req object gives you everything the client sent. Here is the complete inventory.
method#
The HTTP method as an uppercase string: "GET", "POST", "PUT", "PATCH", or "DELETE".
req.method // "GET"path#
The URL path without query parameters:
// Request to /api/users?page=2req.path // "/api/users"params#
Path parameters from the URL pattern (see Chapter 2):
// Route: /users/{id}/posts/{postId}// Request: /users/5/posts/42req.params.id // "5" (or 5 if typed as :id:int)req.params.postId // "42"query#
Query string parameters as an object:
// Request: /search?q=keyboard&page=2&sort=pricereq.query.q // "keyboard"req.query.page // "2"req.query.sort // "price"body#
The parsed request body. JSON requests produce an object. Form submissions contain form fields:
// POST with {"name": "Widget", "price": 9.99}req.body.name // "Widget"req.body.price // 9.99headers#
Request headers as an object. Header names are normalized to lowercase:
req.headers["content-type"] // "application/json"req.headers["authorization"] // "Bearer eyJhbGci..."req.headers["x-custom"] // "my-value"ip#
The client's IP address:
req.ip // "127.0.0.1"Tina4 respects X-Forwarded-For and X-Real-IP headers when behind a reverse proxy.
cookies#
Cookies sent by the client:
req.cookies.session_id // "abc123"req.cookies.preferences // "dark-mode"files#
Uploaded files (covered in detail in section 7):
req.files.avatar // File object with name, type, size, tmpPathInspecting the Full Request#
A route that dumps everything:
import { Router } from "tina4-nodejs";โRouter.post("/debug/request", async (req, res) => { return res.json({ method: req.method, path: req.path, params: req.params, query: req.query, body: req.body, headers: req.headers, ip: req.ip, cookies: req.cookies });});curl -X POST "http://localhost:7148/debug/request?page=1" \ -H "Content-Type: application/json" \ -H "X-Custom: hello" \ -d '{"name": "test"}'{ "method": "POST", "path": "/debug/request", "params": {}, "query": {"page": "1"}, "body": {"name": "test"}, "headers": { "content-type": "application/json", "x-custom": "hello", "host": "localhost:7148", "user-agent": "curl/8.4.0", "accept": "*/*", "content-length": "16" }, "ip": "127.0.0.1", "cookies": {}}3. The Response Object#
The res object is your toolkit for sending data back to the client. Every method returns the response, so you can chain calls.
json() -- JSON Response#
The workhorse for APIs. Pass any object or value and it becomes JSON:
return res.json({ name: "Alice", age: 30 });{"name":"Alice","age":30}Chain with status() for a custom status code:
return res.status(201).json({ id: 7, name: "Widget" });This returns 201 Created with the JSON body.
html() -- Template or Raw HTML Response#
Render a Frond template with data (Chapter 4: Templates goes deep):
return res.render("products.html", { products, title: "Our Products"});Or return raw HTML:
return res.html("<h1>Hello</h1><p>This is HTML.</p>");Sets Content-Type: text/html; charset=utf-8.
text() -- Plain Text Response#
Return plain text:
return res.text("Just a plain string.");Sets Content-Type: text/plain; charset=utf-8.
redirect() -- Redirect Response#
Send the client to a different URL:
return res.redirect("/login");This sends a 302 Found redirect by default. Pass a different status code for permanent redirects:
return res.redirect("/new-location", 301);file() -- File Download Response#
Send a file to the client for download:
return res.file("/path/to/report.pdf");Tina4 sets the appropriate Content-Type based on the file extension and adds a Content-Disposition header so the browser downloads the file.
Set a custom filename:
return res.file("/path/to/report.pdf", "monthly-report-march-2026.pdf");4. Status Codes#
Every response method chains with status(). The most common ones:
| Code | Meaning | When to Use |
|---|---|---|
200 | OK | Default. Successful GET, PUT, PATCH. |
201 | Created | Successful POST that created a resource. |
204 | No Content | Successful DELETE. No body needed. |
301 | Moved Permanently | URL has changed forever. |
302 | Found | Temporary redirect. |
400 | Bad Request | Invalid input from the client. |
401 | Unauthorized | Missing or invalid authentication. |
403 | Forbidden | Authenticated but not allowed. |
404 | Not Found | Resource does not exist. |
409 | Conflict | Duplicate or conflicting data. |
422 | Unprocessable Entity | Valid JSON but fails business rules. |
500 | Internal Server Error | Something broke on the server. |
return res.status(201).json({ id: 7, created: true });5. Custom Headers#
Set response headers with the header() method:
Router.get("/api/data", async (req, res) => { return res .header("X-Request-Id", crypto.randomUUID()) .header("X-Rate-Limit-Remaining", "57") .header("Cache-Control", "no-cache") .json({ data: [1, 2, 3] });});CORS Headers#
Tina4 handles CORS based on the CORS_ORIGINS setting in .env. The default * allows all origins. For production, lock it down:
CORS_ORIGINS=https://myapp.com,https://admin.myapp.com6. Cookies#
Set cookies on the response:
Router.post("/login", async (req, res) => { return res .cookie("session_id", "abc123xyz", { httpOnly: true, secure: true, sameSite: "Strict", maxAge: 3600, path: "/" }) .json({ message: "Logged in" });});Read cookies from the request:
Router.get("/profile", async (req, res) => { const sessionId = req.cookies.session_id ?? null;โ if (sessionId === null) { return res.status(401).json({ error: "Not logged in" }); }โ return res.json({ session: sessionId });});Delete a cookie by setting its maxAge to 0:
return res .cookie("session_id", "", { maxAge: 0, path: "/" }) .json({ message: "Logged out" });7. File Uploads#
Uploaded files arrive via req.files. Each file is an object with metadata and a temporary path.
Multiple File Uploads#
Handle multiple files from a single form field:
Router.post("/api/gallery", async (req, res) => { const files = req.files?.photos;โ if (!files) { return res.status(400).json({ error: "No files uploaded" }); }โ // files is a dict keyed by field name - multiple files under one name become an array const fileList = Object.values(files).flat();โ const results = []; for (const file of fileList) { const ext = extname(file.name); const savedName = `gallery_${randomUUID()}${ext}`; const dest = join(process.cwd(), "src/public/uploads", savedName); await rename(file.tmpPath, dest); results.push({ original: file.name, saved: savedName, size: file.size, url: `/uploads/${savedName}`, }); }โ return res.status(201).json({ uploaded: results, count: results.length });});curl -X POST http://localhost:7148/api/gallery \ -F "photos=@/path/to/photo1.jpg" \ -F "photos=@/path/to/photo2.jpg" \ -F "photos=@/path/to/photo3.jpg"Handling a Single File Upload#
import { Router } from "tina4-nodejs";โRouter.post("/api/upload", async (req, res) => { if (!req.files?.image) { return res.status(400).json({ error: "No file uploaded" }); }โ const file = req.files.image;โ return res.json({ name: file.name, type: file.type, size: file.size, tmp_path: file.tmpPath });});curl -X POST http://localhost:7148/api/upload \ -F "image=@/path/to/photo.jpg"{ "name": "photo.jpg", "type": "image/jpeg", "size": 245760, "tmp_path": "/tmp/tina4_upload_abc123"}Saving the Uploaded File#
import { Router } from "tina4-nodejs";import { rename, mkdir } from "fs/promises";import { existsSync } from "fs";import { join, extname } from "path";import { randomUUID } from "crypto";โRouter.post("/api/upload", async (req, res) => { if (!req.files?.image) { return res.status(400).json({ error: "No file uploaded" }); }โ const file = req.files.image;โ // Validate file type const allowedTypes = ["image/jpeg", "image/png", "image/gif", "image/webp"]; if (!allowedTypes.includes(file.type)) { return res.status(400).json({ error: "Invalid file type. Allowed: JPEG, PNG, GIF, WebP" }); }โ // Validate file size (max 5MB) const maxSize = 5 * 1024 * 1024; if (file.size > maxSize) { return res.status(400).json({ error: "File too large. Maximum size: 5MB" }); }โ // Generate a unique filename const ext = extname(file.name); const filename = `img_${randomUUID()}${ext}`; const uploadDir = join(process.cwd(), "src/public/uploads"); const destination = join(uploadDir, filename);โ if (!existsSync(uploadDir)) { await mkdir(uploadDir, { recursive: true }); }โ await rename(file.tmpPath, destination);โ return res.status(201).json({ message: "File uploaded successfully", filename, url: `/uploads/${filename}`, size: file.size });});8. File Downloads#
Send files to the client using res.file():
import { Router } from "tina4-nodejs";import { existsSync } from "fs";import { join } from "path";โRouter.get("/api/reports/{filename}", async (req, res) => { const filename = req.params.filename; const filepath = join(process.cwd(), "data/reports", filename);โ if (!existsSync(filepath)) { return res.status(404).json({ error: "Report not found" }); }โ return res.file(filepath);});To force a specific download filename:
return res.file(filepath, "Q1-2026-Sales-Report.pdf");9. XML Responses#
Return XML when your API needs to serve legacy clients or SOAP integrations:
Router.get("/api/products/{id:int}.xml", async (req, res) => { const product = { id: req.params.id, name: "Wireless Keyboard", price: 79.99 };โ const xml = `<?xml version="1.0" encoding="UTF-8"?><product> <id>${product.id}</id> <name>${product.name}</name> <price>${product.price}</price></product>`;โ return res.header("Content-Type", "application/xml").text(xml);});10. Content Negotiation#
Check the Accept header to return different formats from the same endpoint:
import { Router } from "tina4-nodejs";โRouter.get("/api/products/{id:int}", async (req, res) => { const id = req.params.id; const product = { id, name: "Wireless Keyboard", price: 79.99 };โ const accept = req.headers["accept"] ?? "application/json";โ if (accept.includes("text/html")) { return res.render("product-detail.html", { product }); }โ if (accept.includes("application/xml")) { const xml = `<?xml version="1.0"?><product><id>${id}</id><name>${product.name}</name><price>${product.price}</price></product>`; return res.header("Content-Type", "application/xml").text(xml); }โ if (accept.includes("text/plain")) { return res.text(`Product #${id}: ${product.name} - $${product.price}`); }โ return res.json(product);});11. Input Validation#
Tina4 includes a Validator class for declarative input validation. Chain rules together and check the result. If validation fails, use res.error() to return a structured error envelope.
The Validator Class#
import { Validator } from "tina4-nodejs";โRouter.post("/api/users", (req, res) => { const v = new Validator(req.body); v.required("name").required("email").email("email").minLength("name", 2);โ if (!v.isValid()) { return res.error("VALIDATION_FAILED", v.errors()[0].message, 400); }โ // proceed with valid data});The Validator accepts the request body (an object) and provides chainable methods:
| Method | Description |
|---|---|
required(field) | Field must be present and non-empty |
email(field) | Field must be a valid email address |
minLength(field, n) | Field must have at least n characters |
maxLength(field, n) | Field must have at most n characters |
numeric(field) | Field must be a number |
inList(field, values) | Field must be one of the allowed values |
Call v.isValid() to check all rules. Call v.errors() to get the array of failures, each with a field and message property.
The Error Response Envelope#
res.error() returns a consistent JSON error envelope:
return res.error("VALIDATION_FAILED", "Name is required", 400);This produces:
{"error": true, "code": "VALIDATION_FAILED", "message": "Name is required", "status": 400}The three arguments are: an error code string, a human-readable message, and the HTTP status code. Use this pattern across your API for consistent error handling.
Upload Size Limits#
Tina4 enforces a maximum upload size via the TINA4_MAX_UPLOAD_SIZE environment variable. The value is in bytes. The default is 10485760 (10 MB).
TINA4_MAX_UPLOAD_SIZE=10485760If a client sends a file larger than this limit, Tina4 returns a 413 Payload Too Large response before your handler runs. To allow larger uploads, increase the value in .env:
TINA4_MAX_UPLOAD_SIZE=52428800This sets the limit to 50 MB.
12. Exercise: Build an Image Upload API#
Build an API that handles image uploads and serves them back.
Requirements#
| Method | Path | Description |
|---|---|---|
POST | /api/images | Upload an image. Validate type and size. Return the image URL. |
GET | /api/images/{filename} | Return the uploaded image file. Return 404 if not found. |
Rules:
- Accept JPEG, PNG, and WebP files only
- Maximum file size: 2MB
- Save files to
src/public/uploads/with a unique filename - Return the original filename, the saved filename, file size in KB, and the URL
- The GET endpoint should serve the file directly (not JSON)
13. Solution#
Create src/routes/images.ts:
import { Router } from "tina4-nodejs";import { rename, mkdir } from "fs/promises";import { existsSync } from "fs";import { join, extname } from "path";import { randomUUID } from "crypto";โRouter.post("/api/images", async (req, res) => { if (!req.files?.image) { return res.status(400).json({ error: "No image file provided. Use field name 'image'." }); }โ const file = req.files.image;โ const allowedTypes = ["image/jpeg", "image/png", "image/webp"]; if (!allowedTypes.includes(file.type)) { return res.status(400).json({ error: "Invalid file type", received: file.type, allowed: allowedTypes }); }โ const maxSize = 2 * 1024 * 1024; if (file.size > maxSize) { return res.status(400).json({ error: "File too large", size_bytes: file.size, max_bytes: maxSize }); }โ const ext = extname(file.name).toLowerCase(); const savedName = `img_${randomUUID()}${ext}`; const uploadDir = join(process.cwd(), "src/public/uploads"); const destination = join(uploadDir, savedName);โ if (!existsSync(uploadDir)) { await mkdir(uploadDir, { recursive: true }); }โ await rename(file.tmpPath, destination);โ return res.status(201).json({ message: "Image uploaded successfully", original_name: file.name, saved_name: savedName, size_kb: Math.round(file.size / 1024 * 10) / 10, type: file.type, url: `/uploads/${savedName}` });});โRouter.get("/api/images/{filename}", async (req, res) => { const filename = req.params.filename;โ if (filename.includes("..") || filename.includes("/")) { return res.status(400).json({ error: "Invalid filename" }); }โ const filepath = join(process.cwd(), "src/public/uploads", filename);โ if (!existsSync(filepath)) { return res.status(404).json({ error: "Image not found", filename }); }โ return res.file(filepath);});Expected output for upload:
{ "message": "Image uploaded successfully", "original_name": "photo.jpg", "saved_name": "img_a1b2c3d4-e5f6-7890-abcd-ef1234567890.jpg", "size_kb": 240.0, "type": "image/jpeg", "url": "/uploads/img_a1b2c3d4-e5f6-7890-abcd-ef1234567890.jpg"}(Status: 201 Created)
14. Gotchas#
1. Forgetting return#
Problem: Your handler runs (log output appears) but the browser shows an empty response or a 500 error.
Cause: You wrote res.json({...}) without return.
Fix: Write return res.json({...}). The response object must be returned from the handler for Tina4 to send it.
2. Body Is Undefined for JSON Requests#
Problem: req.body is undefined or empty even though you are sending JSON.
Cause: Missing Content-Type: application/json header. Without it, Tina4 does not parse the body as JSON.
Fix: Include -H "Content-Type: application/json" when sending JSON with curl. In frontend JavaScript, fetch() with JSON.stringify() requires headers: {"Content-Type": "application/json"}.
3. File Uploads Return Empty#
Problem: req.files is empty even though you are uploading a file.
Cause: The form is not using enctype="multipart/form-data", or the curl command uses -d instead of -F.
Fix: For HTML forms, use <form enctype="multipart/form-data">. For curl, use -F "field=@file.jpg" (with @), not -d.
4. Redirect Loops#
Problem: The browser shows "too many redirects" or hangs.
Cause: Route A redirects to route B, which redirects back to route A.
Fix: Trace the redirect chain in the browser's network inspector. Break the cycle.
5. Cookie Not Set#
Problem: You called res.cookie(...) but the browser does not show the cookie.
Cause: secure: true means the cookie travels only over HTTPS. Local development uses http://localhost. The cookie is dropped.
Fix: Set secure: false during development.
6. Large Request Body Rejected#
Problem: POST requests with large bodies return a 413 error.
Cause: The request body exceeds the configured maximum size.
Fix: Increase TINA4_MAX_UPLOAD_SIZE in .env. The default is 10mb.
7. Headers Are Lowercase in Node.js#
Problem: req.headers["Content-Type"] is undefined even though the header was sent.
Cause: Node.js normalizes all header names to lowercase.
Fix: Use lowercase header names: req.headers["content-type"].