Tina4

Tina4 Node.js - Quick Reference#

๐Ÿ”ฅ Hot Tips

  • Routes go in src/routes/, templates in src/templates/, static files in src/public/
  • GET routes are public by default; POST/PUT/PATCH/DELETE require a token
  • Return an object from res.json() and the framework sets application/json
  • Use tina4 serve to 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.

bash
# 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 serve

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

html
<!-- src/templates/index.html --><h1>Hello Static World</h1>
typescript
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.

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

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

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

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

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

typescript
import { Router, CorsMiddleware, RequestLogger } from "tina4-nodejs";โ€‹Router.use(CorsMiddleware);Router.use(RequestLogger);

Apply middleware to a group and every route inside inherits it:

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

html
<!-- src/templates/welcome.html --><h1>Hello, {{ name }}!</h1>
typescript
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.

ValueBackendConnection settings
file (default)File systemNo extra config
redisRedisTINA4_SESSION_REDIS_URL
valkeyValkeyTINA4_SESSION_VALKEY_HOST
mongodbMongoDBTINA4_SESSION_MONGO_URI
databaseYour ORM DBTINA4_DATABASE_URL
bash
TINA4_SESSION_BACKEND=redisTINA4_SESSION_REDIS_URL=redis://localhost:6379
typescript
import { 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.

scss
// src/scss/main.scss$primary: #2c3e50;body {  background: $primary;  color: white;}

Reference the compiled file from your templates:

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

bash
TINA4_DEBUG=trueTINA4_PORT=7148TINA4_DATABASE_URL=sqlite:///data/app.dbTINA4_LOG_LEVEL=ALLTINA4_API_KEY=ABC1234
typescript
const apiKey = process.env.TINA4_API_KEY ?? "ABC1234";

Access env vars programmatically:

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

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

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

html
<!-- src/templates/register.html --><form method="POST" action="/register">    {{ form_token("Register" ~ random()) }}    <input name="email" type="email">    <button>Save</button></form>
typescript
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.

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

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

bash
# PostgreSQLTINA4_DATABASE_URL=postgres://localhost:5432/myapp# MySQLTINA4_DATABASE_URL=mysql://localhost:3306/myapp

Access the connection from any route. The database speaks whichever dialect the engine understands:

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

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

bash
tina4 generate migration create_users_table
sql
-- UPCREATE TABLE users (    id INTEGER PRIMARY KEY AUTOINCREMENT,    name TEXT NOT NULL);โ€‹-- DOWNDROP TABLE users;
bash
tina4 migrate

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

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

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

bash
tina4 generate crud User

This creates src/routes/users.ts with GET, POST, PUT, PATCH, and DELETE routes. Model, routes, and migration, all connected, ready to serve.

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

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

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

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

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

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

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

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

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

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

Back to top

HTML Builder#

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

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

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

bash
TINA4_LOG_LEVEL=ALL    # DEBUG | INFO | WARN | ERROR | ALL

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

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

bash
curl http://localhost:7148/health
json
{  "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:

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

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

bash
TINA4_DEBUG=true   # enables overlay in browser

Dev Admin#

The /__dev dashboard is available when TINA4_DEBUG=true. It gives a live view of your running application.

http://localhost:7148/__dev

The 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#

bash
# 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 build

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

bash
TINA4_DEBUG=true          # MCP server starts on port 7149TINA4_MCP_PORT=7149       # override the default port

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

typescript
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();          // true

Seed a table in one loop:

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

bash
TINA4_LOCALE=af
typescript
import { localize } from "tina4-nodejs";โ€‹const _ = localize();console.log(_("Server stopped.")); // "Bediener gestop." (af)

Translations fall back to English for unsupported languages.

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