Tina4 Node.js - Quick Reference#
๐ฅ Hot Tips
- Routes go in
src/routes/, templates insrc/templates/, static files insrc/public/ - GET routes are public by default; POST/PUT/PATCH/DELETE require a token
- Return an object from
res.json()and the framework setsapplication/json - Use
tina4 serveto launch the dev server on port 7148 - Node.js 22+ required (uses built-in
node:sqlite)
Installation โข Static Websites โข Routing โข Middleware โข Templates โข Sessions โข SCSS โข Environments โข Authentication โข Forms & Tokens โข AJAX โข OpenAPI โข Databases โข Database Results โข Migrations โข ORM โข CRUD โข REST Client โข Testing โข Services โข Websockets โข Queues โข WSDL โข GraphQL โข Localization โข HTML Builder โข Events โข Logging โข Cache โข Health โข DI Container โข Error Overlay โข Dev Admin โข CLI โข MCP Server โข FakeData
Installation#
The server starts and your browser shows the result.
# Install the tina4 CLI once. Windows: irm https://tina4.com/install.ps1 | iexcurl -fsSL https://tina4.com/install.sh | shโtina4 init nodejs my-appcd my-apptina4 serveThe dev server runs at http://localhost:7148. One dependency. No node-gyp. No native binaries. SQLite uses Node's built-in node:sqlite module.
Static Websites#
Put .html files in src/templates/ and assets in src/public/. The framework serves them without additional configuration.
<!-- src/templates/index.html --><h1>Hello Static World</h1>import { Router } from "tina4-nodejs";โRouter.get("/", async (req, res) => { return res.html("index.html");});More details on static website routing.
Basic Routing#
The Router class maps requests to your code. Define a path. Write a handler. The framework does the rest.
import { Router } from "tina4-nodejs";โRouter.get("/hello", async (req, res) => { return res.json({ message: "Hello, World!" });});โRouter.post("/products", async (req, res) => { return res.status(201).json({ name: req.body.name });});Path parameters use curly braces. Add a type to enforce validation:
Router.get("/products/{id:int}", async (req, res) => { const id = req.params.id; // number, not string return res.json({ product_id: id });});Group routes that share a prefix. The framework prepends the path:
Router.group("/api/v1", (group) => { group.get("/users", async (req, res) => { return res.json({ users: [] }); }); group.post("/users", async (req, res) => { return res.status(201).json({ created: true }); });});Redirect after a POST:
Router.post("/register", async (req, res) => { return res.redirect("/welcome");});Middleware#
Middleware sits between the request and your handler. It runs before, after, or both.
Function-based middleware receives req, res, and next. Call next() to continue. Skip it to block the request.
import { Router } from "tina4-nodejs";โfunction requireApiKey(req, res, next) { const key = req.headers["x-api-key"] ?? ""; if (key !== "my-secret-key") { return res.status(401).json({ error: "Invalid API key" }); } next();}โRouter.get("/api/secret", async (req, res) => { return res.json({ secret: "The answer is 42" });}, [requireApiKey]);Class-based middleware uses naming conventions. Methods starting with before run before the handler. Methods starting with after run after it. Register them with Router.use():
import { Router, CorsMiddleware, RequestLogger } from "tina4-nodejs";โRouter.use(CorsMiddleware);Router.use(RequestLogger);Apply middleware to a group and every route inside inherits it:
Router.group("/api/admin", (group) => { group.get("/dashboard", async (req, res) => { return res.json({ page: "admin dashboard" }); });}, [requireAuth]);Template Rendering#
Place .html files in src/templates/ and static assets in src/public/. The Frond engine reads your template, fills in the variables, and delivers clean HTML.
<!-- src/templates/welcome.html --><h1>Hello, {{ name }}!</h1>import { Router } from "tina4-nodejs";โRouter.get("/welcome", async (req, res) => { return res.html("welcome.html", { name: "Alice" });});Frond supports Twig-compatible syntax: loops, conditionals, extends, blocks, includes, and filters. Zero dependencies. Built from scratch.
Sessions#
The default session handler stores data on the file system. Override TINA4_SESSION_BACKEND in .env to switch backends.
The value is the backend name, not a class name. Unknown values fall back to the file backend, so a typo costs you the backend you asked for without raising.
| Value | Backend | Connection settings |
|---|---|---|
file (default) | File system | No extra config |
redis | Redis | TINA4_SESSION_REDIS_URL |
valkey | Valkey | TINA4_SESSION_VALKEY_HOST |
mongodb | MongoDB | TINA4_SESSION_MONGO_URI |
database | Your ORM DB | TINA4_DATABASE_URL |
TINA4_SESSION_BACKEND=redisTINA4_SESSION_REDIS_URL=redis://localhost:6379import { Router } from "tina4-nodejs";โRouter.get("/session/set", async (req, res) => { req.session.set("name", "Joe"); req.session.set("info", { items: ["one", "two", "three"] }); return res.json({ message: "Session set!" });});โRouter.get("/session/get", async (req, res) => { const name = req.session.get("name"); const info = req.session.get("info"); return res.json({ name, info });});โRouter.get("/session/clear", async (req, res) => { req.session.delete("name"); return res.json({ message: "Session key removed!" });});SCSS Stylesheets#
Drop .scss files in src/scss/. The framework compiles them to src/public/css/ automatically.
// src/scss/main.scss$primary: #2c3e50;body { background: $primary; color: white;}Reference the compiled file from your templates:
<link rel="stylesheet" href="/css/main.css">More details on CSS and SCSS.
Environments#
The .env file holds your project configuration. The framework reads it at startup.
TINA4_DEBUG=trueTINA4_PORT=7148TINA4_DATABASE_URL=sqlite:///data/app.dbTINA4_LOG_LEVEL=ALLTINA4_API_KEY=ABC1234const apiKey = process.env.TINA4_API_KEY ?? "ABC1234";Access env vars programmatically:
import { loadEnv, getEnv, hasEnv, requireEnv, isTruthy } from "tina4-nodejs";โloadEnv(); // Load .env file (auto on server start)getEnv("TINA4_DATABASE_URL"); // Get value or undefinedgetEnv("PORT", "7148"); // Get value with defaulthasEnv("TINA4_DEBUG"); // true if setrequireEnv("TINA4_DATABASE_URL"); // Throws if missingisTruthy(getEnv("TINA4_DEBUG")); // true for "true", "1", "yes"Authentication#
POST, PUT, PATCH, and DELETE routes require a bearer token by default. GET routes are public unless you mark them otherwise.
Use JSDoc annotations to control access. @noauth makes any route public. @secured protects a GET route:
import { Router } from "tina4-nodejs";โ/** * @noauth */Router.post("/api/login", async (req, res) => { const token = Auth.getToken({ user_id: 42 }, secret); return res.json({ token });});โ/** * @secured */Router.get("/api/profile", async (req, res) => { return res.json({ user: req.user });});Chain .secure() on any route for inline protection. Chain .cache() to cache responses:
Router.get("/api/account", async (req, res) => { return res.json({ account: req.user });}).secure().cache();HTML Forms and Tokens#
Tina4 embeds a CSRF form token into every POST form. The framework validates it on submission.
<!-- src/templates/register.html --><form method="POST" action="/register"> {{ form_token("Register" ~ random()) }} <input name="email" type="email"> <button>Save</button></form>import { Router } from "tina4-nodejs";โ/** * @noauth */Router.post("/register", async (req, res) => { const email = req.body.email; // token validated automatically - proceed with form data return res.redirect("/welcome");});More details on form tokens, file uploads, error handling, and disabling auth.
AJAX and frond.js#
Tina4 ships with frond.js, a small zero-dependency JavaScript library for AJAX calls, form submissions, and real-time WebSocket connections.
<script src="/frond.js"></script> <script>frond.get("/api/users", (data) => { console.log(data);}); frond.post("/api/save", { name: "Alice" }, (data) => { console.log(data);});</script>More details on available features.
OpenAPI and Swagger UI#
Add JSDoc annotations to your routes. Visit http://localhost:7148/swagger. Your API documentation appears, interactive and ready to share.
import { Router } from "tina4-nodejs";โ/** * List all products * @description Returns all products in the catalog * @tags Products */Router.get("/api/products", async (req, res) => { return res.json({ products: [] });});Swagger runs when TINA4_DEBUG=true. Set TINA4_SWAGGER_ENABLED=true in .env to expose it in production. The raw spec lives at /swagger/json, standard OpenAPI 3.0, compatible with every tool in the OpenAPI world.
Databases#
Tina4 defaults to SQLite at data/app.db. Set TINA4_DATABASE_URL in .env to switch engines. The API stays identical across SQLite, PostgreSQL, MySQL, SQL Server, and Firebird.
# PostgreSQLTINA4_DATABASE_URL=postgres://localhost:5432/myapp# MySQLTINA4_DATABASE_URL=mysql://localhost:3306/myappAccess the connection from any route. The database speaks whichever dialect the engine understands:
import { Database } from "tina4-nodejs";โconst db = Database.getConnection();const result = await db.fetch("SELECT * FROM users LIMIT 10");Follow the links for more on Available Connections, Core Methods, Usage and Full transaction control.
Database Results#
import { Database } from "tina4-nodejs";โconst db = Database.getConnection();const result = await db.fetch("SELECT * FROM test_record ORDER BY id", 3, 1);โconst array = result.toArray();const paginated = result.toPaginate();const csvData = result.toCsv();const jsonData = result.toJson();Looking at detailed Usage will deepen your understanding.
Migrations#
Generate a timestamped migration file. Write your SQL. Run it. The framework tracks what has run.
tina4 generate migration create_users_table-- UPCREATE TABLE users ( id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL);โ-- DOWNDROP TABLE users;tina4 migrateRoll back with tina4 migrate --rollback. Check status with tina4 migrate --status. Each migration has UP and DOWN sections, so every change is reversible.
ORM#
ORM models live in src/orm/. Each file maps a class to a table. Define fields, types, and constraints. The ORM handles the rest.
import { BaseModel } from "tina4-nodejs/orm";โexport class User extends BaseModel { static tableName = "users"; static fields = { id: { type: "integer", primaryKey: true, autoIncrement: true }, name: { type: "string", required: true }, email: { type: "string", required: true }, };โ id!: number; name!: string; email!: string;}Property names are camelCase. Column names are snake_case. Tina4 converts between them automatically.
// Createconst user = new User();user.name = "Alice";user.email = "alice@example.com";await user.save();โ// Readconst found = await User.findById(1);โ// Deleteawait found.delete();ORM covers more ground than this snippet shows. Study the Advanced Detail to get the full value.
CRUD#
The CLI generates a full CRUD route file from your model. One command wires up all five REST endpoints:
tina4 generate crud UserThis creates src/routes/users.ts with GET, POST, PUT, PATCH, and DELETE routes. Model, routes, and migration, all connected, ready to serve.
Router.get("/users/dashboard", async (req, res) => { const users = await User.select("id, name, email"); return res.html("users/dashboard.html", { crud: users.toCrud(req) });});More details on how CRUD generates its files and where they live.
Consuming REST APIs#
import { Api } from "tina4-nodejs";โconst api = new Api("https://api.example.com", { authHeader: "Bearer xyz" });const result = await api.get("/users/42");console.log(result.body);โ// POST with a payloadconst created = await api.post("/users", { name: "Alice" });console.log(created.body);More details on sending POST data, authorization headers, and other controls for outbound API requests.
Inline Testing#
import { tests, assertEqual, assertRaises } from "tina4-nodejs";โ@tests( assertEqual([7, 7], 1), assertEqual([-1, 1], -1), assertRaises(Error, [5, 0]),)function divide(a: number, b: number): number { if (b === 0) throw new Error("division by zero"); return a / b;}Run: tina4 test
Services#
Services are long-running background processes that start with your application. Define a class that extends Service and implement the run() method.
import { Service } from "tina4-nodejs";โexport class CleanupService extends Service { async run(): Promise<void> { setInterval(async () => { await db.execute("DELETE FROM sessions WHERE expires_at < NOW()"); }, 60_000); }}Register it in your app entry point:
import { App } from "tina4-nodejs";import { CleanupService } from "./services/CleanupService";โApp.register(CleanupService);Websockets#
Define a WebSocket handler the same way you define an HTTP route. The connection is persistent and bi-directional. No ws or socket.io required.
import { Router } from "tina4-nodejs";โRouter.websocket("/ws/chat", (connection, event, data) => { if (event === "open") { connection.send("Welcome to chat!"); } if (event === "message") { connection.send(`Echo: ${data}`); } if (event === "close") { console.log("Client disconnected"); }});The callback receives three arguments: connection (send messages through it), event ("open", "message", or "close"), and data (the message text). WebSocket runs alongside your HTTP server on the same port.
Have a look at the PubSub example under WebSockets.
Queues#
Push slow work to the background. The user gets a response in milliseconds. The work still happens, just not during the request.
import { Router, Queue } from "tina4-nodejs";โconst queue = new Queue({ topic: "emails" });โRouter.post("/api/register", async (req, res) => { queue.push({ to: req.body.email, subject: "Welcome!" }); return res.status(201).json({ message: "Registered" });});Consume jobs with the generator. Each job must be completed or failed:
for (const job of queue.consume("emails")) { try { await sendEmail(job.payload.to, job.payload.subject); job.complete(); } catch (e) { job.fail(e.message); }}No Redis. No RabbitMQ. The file-based backend works out of the box.
WSDL#
import { WSDL, wsdlOperation, wsdlRoute } from "tina4-nodejs";โclass Calculator extends WSDL { static serviceUrl = "http://localhost:7148/calculator";โ Add(a: number, b: number): object { return { Result: a + b }; }โ SumList(Numbers: number[]): object { return { Numbers, Total: Numbers.reduce((s, n) => s + n, 0), Error: null, }; }}โ@wsdlRoute("/calculator")Router.post("/calculator", async (req, res) => { return res.wsdl(new Calculator(req));});More Details on WSDL configuration and usage.
GraphQL#
Tina4 ships a built-in GraphQL endpoint. Define your schema and resolvers. The framework mounts them at /graphql.
import { GraphQL } from "tina4-nodejs";โconst schema = ` type User { id: Int name: String email: String }โ type Query { user(id: Int!): User users: [User] }`;โconst resolvers = { Query: { user: async (_: unknown, { id }: { id: number }) => { return User.findById(id); }, users: async () => { return User.select("*"); }, },};โGraphQL.register({ schema, resolvers });Visit http://localhost:7148/graphql to query your API. The GraphiQL playground is available when TINA4_DEBUG=true.
More details on mutations, subscriptions, and authentication.
HTML Builder#
import { HtmlElement, addHtmlHelpers } from "tina4-nodejs";โconst el = new HtmlElement("div", { class: "card" }, ["Hello"]);el.toString(); // '<div class="card">Hello</div>'โ// Nestingconst card = new HtmlElement("div")( new HtmlElement("h2")("Title"), new HtmlElement("p")("Content"),);โ// Helper functionsconst h: Record<string, any> = {};addHtmlHelpers(h);const html = h._div({ class: "card" }, h._h1("Title"), h._p("Description"));Events#
Tina4 ships a built-in event bus. Emit from anywhere. Listen from anywhere. No third-party library required.
import { Events } from "tina4-nodejs";โ// Subscribe - runs every time the event firesEvents.on("user.registered", (payload) => { console.log("New user:", payload.email);});โ// Subscribe once - unsubscribes automatically after the first fireEvents.once("app.ready", () => { console.log("App is up. One-time setup done.");});โ// Emit - synchronous fan-out to all listenersEvents.emit("user.registered", { email: "alice@example.com" });Use events to decouple modules. A route emits. A service listens. Neither knows the other exists.
Logging#
import { Log } from "tina4-nodejs";โLog.info("Server started on port 7148");Log.debug("Query result:", result);Log.warn("Cache miss - falling back to database");Log.error("Payment gateway timeout", err);Set the minimum level in .env:
TINA4_LOG_LEVEL=ALL # DEBUG | INFO | WARN | ERROR | ALLOutput goes to stdout in development and to logs/app.log in production. Each line is timestamped and tagged with the level. Log.debug() is suppressed when TINA4_DEBUG=false.
Response Cache#
Chain .cache() onto any route to cache the full response. Repeat requests skip the handler entirely.
import { Router } from "tina4-nodejs";โ// Cache with default TTL (60 seconds)Router.get("/api/products", async (req, res) => { const products = await db.fetch("SELECT * FROM products"); return res.json(products.toArray());}).cache();โ// Custom TTL in secondsRouter.get("/api/categories", async (req, res) => { return res.json({ categories: await Category.select("*") });}).cache(300);โ// Combine with authRouter.get("/api/dashboard", async (req, res) => { return res.json({ stats: await buildStats() });}).secure().cache(120);The cache key is the full request path. The store is in-memory by default. Set TINA4_CACHE_BACKEND=redis and TINA4_CACHE_URL to share cache across instances.
Health Endpoint#
Tina4 exposes /health without any configuration. Point your load balancer or uptime monitor at it.
curl http://localhost:7148/health{ "status": "ok", "uptime": 3742, "timestamp": "2026-04-03T08:00:00.000Z"}Returns 200 OK when the application is running. Returns 503 Service Unavailable if a registered health check fails. Register custom checks in your app entry point:
import { Health } from "tina4-nodejs";โHealth.register("database", async () => { await db.fetch("SELECT 1");});DI Container#
The built-in dependency injection container wires up services without manual instantiation.
import { Container } from "tina4-nodejs";โ// Transient - new instance per requestContainer.register("mailer", () => new Mailer(process.env.TINA4_MAIL_HOST));โ// Singleton - one instance for the lifetime of the processContainer.singleton("config", () => new AppConfig());โ// Resolve anywhereconst mailer = Container.get<Mailer>("mailer");await mailer.send({ to: "alice@example.com", subject: "Hi" });Services registered before App.start() are available in every route, middleware, and service. Circular dependencies throw at registration time, not at runtime.
Error Overlay#
When TINA4_DEBUG=true, unhandled errors render a full-screen overlay in the browser instead of a blank page or a plain 500 response.
The overlay shows:
- The error message and stack trace
- The source file and line number
- The incoming request path and method
No configuration needed. Set TINA4_DEBUG=false in production and the overlay disappears. The framework returns a plain 500 JSON response instead.
TINA4_DEBUG=true # enables overlay in browserDev Admin#
The /__dev dashboard is available when TINA4_DEBUG=true. It gives a live view of your running application.
http://localhost:7148/__devThe dashboard shows:
- All registered routes with their methods, paths, and middleware
- Active sessions and their keys
- Queue depths per topic
- Recent log entries
- Environment variables (values redacted for secrets)
No setup required. The dashboard disappears in production when TINA4_DEBUG=false.
CLI Commands#
# Scaffold a new projecttina4 init my-projectโ# Start the dev server (hot-reload, port 7148)tina4 serveโ# Run inline teststina4 testโ# Generate a migration filetina4 generate migration create_orders_tableโ# Run pending migrationstina4 migrateโ# Roll back the last migrationtina4 migrate --rollbackโ# Show migration statustina4 migrate --statusโ# Generate a full CRUD route file from a modeltina4 generate crud Orderโ# Build for productiontina4 buildAll commands run from the project root. tina4 --help lists every command with a short description.
MCP Server#
Tina4 starts a Model Context Protocol server automatically when TINA4_DEBUG=true. AI tools (Cursor, Claude Code, VS Code Copilot) connect to it and gain live awareness of your running application.
TINA4_DEBUG=true # MCP server starts on port 7149TINA4_MCP_PORT=7149 # override the default portThe MCP server exposes:
- Route registry: every path, method, and handler location
- ORM schema: all models and their field definitions
- Migration history: applied and pending migrations
- Log stream: live tail of the application log
Connect your AI tool to http://localhost:7149/mcp and it reads your codebase in context. No plugin. No extra install.
FakeData#
FakeData generates realistic test data. Use it in tests, seeders, and fixtures. Zero external dependencies.
import { FakeData } from "tina4-nodejs";โconst fake = new FakeData();โfake.name(); // "Alice Hartley"fake.email(); // "alice.hartley@example.com"fake.phone(); // "+27 82 555 0123"fake.address(); // "14 Oak Street, Cape Town, 8001"fake.company(); // "Hartley & Sons Ltd"fake.sentence(); // "The quick brown fox jumps over the lazy dog."fake.number(1, 100); // 42fake.uuid(); // "f47ac10b-58cc-4372-a567-0e02b2c3d479"fake.date("Y-m-d"); // "2025-11-03"fake.bool(); // trueSeed a table in one loop:
for (let i = 0; i < 50; i++) { await db.execute( "INSERT INTO users (name, email) VALUES (?, ?)", [fake.name(), fake.email()] );}\### Localization (i18n) {#localization}
Set TINA4_LOCALE in .env to change the framework language. Supported: en, fr, af.
TINA4_LOCALE=afimport { localize } from "tina4-nodejs";โconst _ = localize();console.log(_("Server stopped.")); // "Bediener gestop." (af)Translations fall back to English for unsupported languages.
import { AVAILABLE_LANGUAGES } from "tina4-nodejs";// ["en", "fr", "af"]๐ Download the book#
Tina4 for Node.js Developers (PDF): full reference, printable, with clickable table of contents and PDF outline. Regenerated with every release.