@nitronjs/framework
v0.7.2
Published
NitronJS is a modern and extensible Node.js MVC framework built on Fastify. It focuses on clean architecture, modular structure, and developer productivity, offering built-in routing, middleware, configuration management, CLI tooling, and native React int
Maintainers
Readme
Table of Contents
- Quick Start
- Core Concepts — Server/Client Components, Layouts, Routing, Host-Based Routing (Domains), Signed URLs, Controllers, Path Aliases
- Request & Response
- Database — Models, Query Builder, DB Facade, Transactions, Migrations, Seeders
- Authentication — Login, Signing Out Other Devices, Guards, Multi-Factor Auth
- Sessions
- Validation
- Middleware
- Real-Time (Socket.IO) —
Route.io, namespace + event middleware, broadcast helpers - Localization (i18n)
- Queue
- Storage
- Utilities — Encryption, Hashing, Logging, DateTime, Faker, Strings
- Global Functions
- CLI Commands
- Project Structure
- CSS & Tailwind
- Configuration
- Deployment
- Requirements
Quick Start
npx -y @nitronjs/framework my-app
cd my-app
npm run storage:link
npm run devYour app will be running at http://localhost:3000
Core Concepts
Server Components (Default)
Every .tsx file in resources/views/ is a Server Component by default. They run on the server and have full access to your database and file system.
// resources/views/Site/Home.tsx
import User from '@models/User';
export default async function Home() {
const users = await User.get();
return (
<div>
<h1>Users</h1>
<ul>
{users.map(user => (
<li key={user.id}>{user.name}</li>
))}
</ul>
</div>
);
}Client Components
Add "use client" at the top of a file to make it interactive. These components hydrate on the browser and can use React hooks.
"use client";
import { useState } from 'react';
export default function Counter() {
const [count, setCount] = useState(0);
return (
<button onClick={() => setCount(count + 1)}>
Count: {count}
</button>
);
}Client components cannot import server-only modules (the database,
DateTime, etc.). Pass server data down as props from a Server Component instead.
Layouts
Create Layout.tsx files to wrap pages. Layouts are discovered automatically by walking up the directory tree. During SPA navigation, layouts persist and only page content is updated — no full page reloads.
resources/views/
├── Layout.tsx # Root layout (wraps everything)
├── Site/
│ └── Home.tsx # Uses root Layout
└── Admin/
├── Layout.tsx # Admin layout (nested inside root)
└── Dashboard.tsx # Uses both layoutsRouting
Define routes in routes/web.js:
import { Route } from '@nitronjs/framework';
import HomeController from '../app/Controllers/HomeController.js';
import UserController from '../app/Controllers/UserController.js';
// Basic routes
Route.get('/', HomeController.index).name('home');
Route.get('/about', HomeController.about).name('about');
// Route parameters
Route.get('/users/:id', UserController.show).name('user.show');
// RESTful routes
Route.post('/users', UserController.store).name('user.store');
Route.put('/users/:id', UserController.update).name('user.update');
Route.delete('/users/:id', UserController.destroy).name('user.destroy');
// Route groups with prefix, name prefix, and middleware
Route.prefix('/admin').name('admin.').middleware('auth').group(() => {
Route.get('/', DashboardController.index).name('dashboard');
Route.get('/users', AdminUserController.index).name('users');
// Nested groups
Route.prefix('/pages').name('pages.').group(() => {
Route.get('/:id/edit', PagesController.edit).name('edit');
});
});
// Middleware-only groups
Route.middleware('guest').group(() => {
Route.get('/login', AuthController.getLogin).name('login');
Route.post('/login', AuthController.postLogin);
});CSRF protection is applied automatically to the HTTP methods configured in config/session.js — you do not add it to routes manually.
URL Generation
The global route() function is available everywhere — controllers, views, and client-side code:
// Basic
route('home') // => "/"
// With parameters
route('user.show', { id: 1 }) // => "/users/1"
// With query string
route('admin.users', {}, { page: 2, q: 'search' }) // => "/admin/users?page=2&q=search"
// Parameters + query string
route('admin.pages.edit', { id: 5 }, { tab: 'seo' }) // => "/admin/pages/5/edit?tab=seo"
// Absolute URL — for e-mail / webhook links rendered outside the browser (server-side)
route('admin.updates.show', { id: 6 }, {}, { absolute: true }) // => "http://localhost:3000/admin/updates/6"{ absolute: true } prefixes the application origin (APP_URL + APP_PORT, the port appended only when it isn't 80/443). For an absolute non-route URL — e.g. an e-mail logo asset — use the appUrl(path) global, which returns origin + path:
appUrl('/storage/upload_files/logo.png') // => "http://localhost:3000/storage/upload_files/logo.png"Never hand-compose
`${process.env.APP_URL}${path}`for these —APP_URLcarries no port, so the link would point at the wrong one.
Host-Based Routing (Domains)
Route.domain(pattern) constrains a route group to a host — the building block for multi-tenant apps where the same path serves different pages per domain. The Host header is matched with its port stripped, so dev URLs like app.localhost:3000 match cleanly.
Three pattern forms:
Route.domain('app.com') // exact host
Route.domain('*.app.com') // single-segment subdomain (ali.app.com, not a.b.app.com)
Route.domain(/^(?:.+)\.app\.io$/) // raw RegExp for anything elseMulti-tenant layout — apex domain constrained, tenant sites as the unconstrained fallback:
// SaaS (apex)
Route.domain('app.com').group(() => {
Route.get('/', Home.getHome).name('home');
Route.get('/register', Register.getRegister).name('register');
});
// Tenant sites (subdomains + custom domains fall through here)
Route.middleware('tenant').group(() => {
Route.get('/', Site.getHome).name('site.home');
});Rules of the road:
- A path may be registered once per domain group plus one unconstrained fallback; two unconstrained registrations of the same path throw
FST_ERR_DUPLICATED_ROUTEat startup. - Route names must be unique across domain groups —
route()/Router.url()return the first match. - Behind a reverse proxy, enable
web_server.trustProxyand forward the realHostheader so constraint matching and your tenant middleware see the same value.
Signed URLs
Some links have to prove they came from your app and were not edited on the way: a one-time login handoff, an e-mail verification link, an unsubscribe URL. URL appends an HMAC-SHA256 signature (keyed with APP_KEY) to a named route, and the built-in signed middleware answers 403 when a request arrives tampered with or expired.
import { URL } from '@nitronjs/framework';
URL.signedRoute('unsubscribe', { user: 3 });
// → http://localhost:3000/unsubscribe/3?signature=4ad1…
URL.temporarySignedRoute('password.reset', 900, { user: 3 });
// → http://localhost:3000/password/reset/3?expires=1769000900&signature=7bc4…// routes/web.js
Route.get('/unsubscribe/:user', Newsletter.unsubscribe).middleware('signed').name('unsubscribe');signed ships with the framework, so it needs no Kernel.js entry; declaring your own signed alias overrides it.
Nothing is persisted. The signature covers the host, the path, the query string and the expires stamp, so editing any of them — including pushing the expiry further out — invalidates the link. Scheme and port are deliberately excluded so a link keeps validating behind a TLS-terminating reverse proxy.
Cross-host handoff. Session cookies are host-scoped: an authenticated session on app.com does not reach ali.app.com. options.origin signs a link for a different host, which lets one host hand a verified identity to another — with loginUsingId() establishing the session on arrival.
// app.com — credentials verified here, link minted for the tenant host
const url = URL.temporarySignedRoute(
'tenant.handoff',
60,
{ user: user.id },
{},
{ origin: 'https://ali.app.com' }
);// ali.app.com — the signed middleware already proved the link
static async getHandoff(req, res) {
await req.auth.guard('tenant').loginUsingId(req.params.user);
return res.redirect('/panel');
}Because the host is part of the signed payload, a link minted for ali.app.com is rejected on veli.app.com.
Controllers
Controllers are plain classes with static async route handlers. Each handler receives req and res.
// app/Controllers/UserController.js
import User from '../Models/User.js';
class UserController {
static async index(req, res) {
const users = await User.get();
return res.view('User/Index', {
users
});
}
static async show(req, res) {
const user = await User.find(req.params.id);
if (!user) {
return res.code(404).view('Errors/NotFound');
}
return res.view('User/Show', {
user
});
}
static async store(req, res) {
const user = new User();
user.name = req.body.name;
user.email = req.body.email;
await user.save();
return res.redirect(route('user.show', { id: user.id }));
}
}
export default UserController;Path Aliases
NitronJS provides built-in path aliases for clean imports in .tsx view files:
| Alias | Path |
|---|---|
| @models/* | app/Models/* |
| @controllers/* | app/Controllers/* |
| @middlewares/* | app/Middlewares/* |
| @views/* | resources/views/* |
| @css/* | resources/css/* |
| @/* | Project root |
Request & Response
The req object
req is the Fastify request, with req.session and req.auth added by the framework.
| Property / Method | Description |
|---|---|
| req.params | Route parameters (/users/:id → req.params.id) |
| req.body | Parsed request body. Bracket names are expanded: title[tr] → req.body.title.tr |
| req.query | Query string parameters, expanded the same way: ?title[tr]=x → req.query.title.tr |
| req.headers | Request headers |
| req.cookies | Parsed cookies |
| req.ip | Client IP address |
| req.url / req.method | Request URL and HTTP method |
| req.isMultipart() | true for multipart/form-data requests (file uploads) |
| req.locale | Current locale (set by the i18n layer) |
| req.session | Session instance — see Sessions |
| req.auth | Authentication helper — see Authentication |
Bracket notation
A field named title[tr] arrives as a nested value, and it does so whichever way the
form was submitted:
<input name="title[tr]">
<input name="title[en]">// POST → req.body.title.tr
// GET → req.query.title.trThe two used to disagree — a body nested, a query string did not — so a filter form
switched from POST to GET returned nothing and reported no error. Keys that would reach
the prototype (__proto__, constructor, prototype) are dropped rather than
expanded, in both.
The empty-bracket form (tags[]=a&tags[]=b) lands under an empty key
(req.query.tags['']), not as a plain array. A repeated plain key still becomes an
array: ?a=1&a=2 → req.query.a === ['1', '2'].
The res object
res is the Fastify reply, with res.view() added by the framework.
| Method | Description |
|---|---|
| res.view(name, params = {}) | Render a React Server Component view as HTML. Throws 404 if the view does not exist. |
| res.send(payload) | Send a response. Objects/arrays are auto-serialized as JSON. |
| res.code(statusCode) | Set the HTTP status code (chainable). |
| res.redirect(url) | Redirect the client. |
| res.type(contentType) | Set the Content-Type header. |
| res.header(key, value) | Set a response header. |
| res.setCookie(name, value, options) | Set a cookie. |
| res.sendFile(path) | Stream a file as the response. |
// HTML view
return res.view('User/Show', { user });
// JSON (object is auto-serialized)
return res.send({ status: 'success', data: user });
// JSON with a status code
return res.code(422).send({ errors: validation.errors() });
// Redirect
return res.redirect(route('home'));Use
res.code(...)to set the status — there is nores.status(). To return JSON, just pass an object tores.send(...)— there is nores.json().
Database
Models
A model is a class extending Model with a static table property. That is the only required configuration — the primary key is always id.
import { Model } from '@nitronjs/framework';
class User extends Model {
static table = 'users';
}
export default User;Query Builder
Static query methods are available directly on the model.
// Fetch
const users = await User.get();
const user = await User.find(1);
const first = await User.where('email', '[email protected]').first();
// Where clauses
await User.where('role', 'admin').get();
await User.where('age', '>=', 18).get();
await User.where({ role: 'admin', active: 1 }).get();
await User.orWhere('role', 'editor').get();
await User.whereIn('id', [1, 2, 3]).get();
await User.whereNotIn('status', ['banned']).get();
await User.whereBetween('age', [18, 65]).get();
await User.whereNot('role', 'guest').get();
// Parenthesised group - keeps an OR scoped to the columns you meant
await User
.where('active', 1)
.where(query => {
query.where('name', 'LIKE', `%${term}%`);
query.orWhere('email', 'LIKE', `%${term}%`);
})
.get();
// → WHERE active = ? AND (name LIKE ? OR email LIKE ?)
//
// Without the group, `.where('active', 1).orWhere('name', 'LIKE', ...)` compiles to
// `WHERE active = ? OR name LIKE ?` - AND binds tighter than OR, so rows that fail the
// first condition still come back.
// Selecting, ordering, paginating
await User
.select('id', 'name', 'email')
.where('active', 1)
.orderBy('created_at', 'desc')
.limit(10)
.offset(20)
.get();
// Joins & grouping
await User
.join('posts', 'users.id', '=', 'posts.user_id')
.groupBy('users.id')
.get();
// Aggregates
const total = await User.count();
const distinct = await User.countDistinct('email');
const maxAge = await User.max('age');
const minAge = await User.min('age');
const sumScore = await User.sum('score');
const avgScore = await User.avg('score');
// Raw expressions
await User.selectRaw('COUNT(*) as total, role').groupBy('role').get();
// Create
const user = new User();
user.name = 'John';
user.email = '[email protected]';
await user.save();
// Update — throws without a WHERE condition; use a raw query for a table-wide update
await User.where('id', 1).update({ name: 'Jane' });
// Delete — throws without a WHERE condition; use truncate() to clear the table
await User.where('id', 1).delete();
// Serialize an instance to a plain object
const data = user.toJSON();DB Facade
For queries that don't map cleanly to a single model, use the DB facade directly.
import { DB } from '@nitronjs/framework';
// Query builder against any table
const rows = await DB.table('users').where('active', 1).get();
// Raw SQL with bound parameters
const result = await DB.rawQuery('SELECT * FROM users WHERE id = ?', [1]);
// Raw expression inside a query builder chain
await DB.table('users').select(DB.rawExpr('COUNT(*) as total')).first();
// Branch on database availability — false when DATABASE_DRIVER=none
if (DB.isEnabled()) {
await DB.table('users').get();
}DB.isEnabled() returns false for a database-less app (DATABASE_DRIVER=none) and true once a driver is active. Use it to skip DB-dependent work instead of re-reading process.env.DATABASE_DRIVER — this is how the deploy pipeline skips migrations on a DB-less target.
Transactions
DB.transaction() runs a callback inside a database transaction. It commits on success and rolls back if the callback throws.
import { DB } from '@nitronjs/framework';
await DB.transaction(async (trx) => {
await trx.table('accounts').where('id', 1).update({ balance: 900 });
await trx.table('accounts').where('id', 2).update({ balance: 1100 });
await trx.rawQuery('INSERT INTO transfers (amount) VALUES (?)', [100]);
});The trx object exposes table(), rawQuery(), query(), and execute() — all scoped to the transaction's connection. A transaction has a 30-second default timeout.
Row locking
A plain read inside a transaction is a snapshot read, so a read-then-write counter is not safe on its own: two concurrent transactions both see the old value and write the same next value. lockForUpdate() appends FOR UPDATE, which write-locks the selected rows until the transaction ends and makes the second transaction wait.
await DB.transaction(async (trx) => {
const row = await trx.table('settings')
.where('setting_key', 'next_invoice_no')
.lockForUpdate()
.first();
await trx.table('settings')
.where('setting_key', 'next_invoice_no')
.update({ setting_value: String(Number(row.setting_value) + 1) });
return Number(row.setting_value);
});lockForUpdate() outside a transaction locks only for the duration of that single statement, which is rarely what you want.
Reading a failed write. In production a query error is replaced with a generic message and its sql, bindings, errno and sqlState are dropped, so nothing about the statement reaches the caller. The driver's code is kept, because it names the kind of failure rather than the data: a caller can branch on ER_DUP_ENTRY to treat a lost race as "someone else got there first" and rethrow everything else. Outside production the driver's error is handed back untouched.
try {
await response.save();
}
catch (err) {
if (err?.code !== 'ER_DUP_ENTRY') {
throw err;
}
Log.warn('Row already created by a concurrent request, skipping', { complaint_id });
}Migrations
npm run make:migration create_posts_tableMigrations live in database/migrations/ and use the Schema builder:
import { Schema } from '@nitronjs/framework';
class CreatePostsTable {
static async up() {
await Schema.create('posts', (table) => {
table.id();
table.string('title');
table.text('body');
table.string('slug').unique();
table.boolean('published').default(false);
table.json('metadata').nullable();
table.foreign('user_id').references('id').on('users').onDelete('CASCADE');
table.timestamp('created_at');
table.timestamp('updated_at').nullable();
});
}
static async down() {
await Schema.dropIfExists('posts');
}
}
export default CreatePostsTable;Schema Builder Reference
Column types: id(), string(name, length = 255), text(), integer(), bigInteger(), boolean(), timestamp(), json()
Modifiers: .nullable(), .default(value), .unique(), .index() — table level: t.index(columns), t.unique(columns), t.dropIndex(name), t.dropColumn(name)
There is no timestamps() shorthand — declare t.timestamp('created_at') and t.timestamp('updated_at').nullable() explicitly. A non-nullable timestamp without a default gets DEFAULT CURRENT_TIMESTAMP.
Column types: id(), string(name, length?), text(), integer(), bigInteger(), boolean(), timestamp(), json() — that is the whole set.
Column modifiers (chainable): .nullable(), .default(value), .unique(), .index()
Indexes: table.index(columns), table.unique(columns), table.dropIndex(name)
Dropping a column: table.dropColumn(name) - what an additive migration needs in its down(). Drop the index before the column it covers.
A unique index can be declared on the column (t.string('email').unique()) or on the table (t.unique(['response_id', 'question_id'])); both emit the same statement. Only the table-level form reaches a column that already exists, which is how a later migration adds a constraint:
await Schema.table('survey_responses', table => {
table.dropIndex('idx_survey_responses_complaint_id');
table.unique('complaint_id');
});Names are generated: idx_<table>_<columns> for a plain index, uniq_<table>_<columns> for a unique one - that is what dropIndex() takes.
Altering tables: Schema.table() adds the columns the callback declares, then their indexes, then the indexes it drops. There is no way to drop, rename or change an existing column — write a new migration that adds what you need, or recreate the table.
await Schema.table('trainers', table => {
table.string('custom_domain_token').nullable();
table.timestamp('custom_domain_verified_at').nullable();
});Schema methods: Schema.create(), Schema.createIfNotExists(), Schema.table() (alter), Schema.dropIfExists()
npm run migrate # Run pending migrations
npm run migrate:safe # Run migrations after an automatic DB backup
npm run migrate:fresh # Drop all tables and re-run
npm run migrate:fresh:seed # Drop, migrate, and seed
npm run migrate:rollback # Rollback last batch
npm run migrate:status # Show migration statusSeeders
npm run make:seeder UserSeederSeeders live in database/seeders/. Each seeder is a class with a static async run() method:
import { Hash } from '@nitronjs/framework';
import User from '../../app/Models/User.js';
class UserSeeder {
static async run() {
const user = new User();
user.name = 'Admin';
user.email = '[email protected]';
user.password = await Hash.make('password');
await user.save();
}
}
export default UserSeeder;Run seeders with npm run seed (runs every seeder, alphabetically) or npx njs seed UserSeeder (one seeder). Seeders can call other seeders by importing them and calling .run() directly.
Authentication
Login & Logout
req.auth provides authentication scoped to the default guard.
class AuthController {
static async postLogin(req, res) {
const success = await req.auth.attempt({
email: req.body.email,
password: req.body.password
});
if (!success) {
return res.view('Auth/Login', {
error: 'Invalid credentials'
});
}
return req.auth.home();
}
static async logout(req, res) {
await req.auth.logout();
return res.redirect(route('home'));
}
}| Method | Description |
|---|---|
| await req.auth.attempt(credentials) | Validate credentials and log the user in. Returns boolean. |
| await req.auth.loginUsingId(id) | Log a user in by primary key, without checking a password. Returns boolean. |
| await req.auth.user() | The authenticated user, or null. |
| req.auth.check() | true if the session holds an identity (synchronous, session-only). Gate access with user() - see note below. |
| await req.auth.logout() | End the session. |
| await req.auth.logoutOtherDevices() | Destroy every other session of the current user, keeping this one. Returns the number destroyed. |
| req.auth.home() | Redirect to the guard's configured home route. |
| req.auth.redirect() | Redirect to the guard's configured redirect route. |
| req.auth.guard(name) | Same API, scoped to a named guard. |
Signing Out Other Devices
Changing a password is meaningless against a stolen session if that session keeps working.
logoutOtherDevices() deletes every stored session that belongs to the current user except
the one making the request, so the device that changed the password stays signed in and every
other device is signed out.
static async postPassword(req, res) {
const user = await req.auth.user();
user.password = await Hash.make(req.body.password);
await user.save();
await req.auth.logoutOtherDevices();
return res.send({
status: 'success'
});
}Sessions are keyed by a random id, so the store finds the user's other sessions by matching the
auth key held inside each session payload. Every driver implements this: memory and file walk
their entries, redis walks the keyspace with SCAN (never KEYS, which blocks the server).
The sweep is meant for rare calls such as a password change, not for per-request use.
Call it after the new password is saved. Calling it before leaves a window where another device could still act with the old credentials.
Guards
Guards let one app authenticate multiple user types (e.g. user and admin). They are defined in config/auth.js:
import User from '../app/Models/User.js';
import Admin from '../app/Models/Admin.js';
export default {
defaults: {
guard: 'user'
},
guards: {
user: {
provider: User,
identifier: 'email',
home: 'dashboard',
redirect: 'login'
},
admin: {
provider: Admin,
identifier: 'username',
home: 'admin.dashboard',
redirect: 'admin.login'
}
}
};// Use a specific guard
await req.auth.guard('admin').attempt({ username, password });
const admin = await req.auth.guard('admin').user();Gate access with user(), not check()
check() only asks whether the session carries an identity; it never reads the database. A session
outlives the record it points to, so after an account is deleted check() still returns true
while user() returns null. A guard built on check() lets the stale session through and the
next line that touches the user crashes - and a Guest middleware built on check() bounces that
same session back to the dashboard, producing a redirect loop between login and home.
// ✅ asks whether a valid user exists
class Authentication {
static async handler(req, res, guardName = 'user') {
if (!await req.auth.guard(guardName).user()) {
return req.auth.guard(guardName).redirect();
}
}
}
// ❌ asks only whether the session is non-empty
if (!req.auth.guard(guardName).check()) {
return req.auth.guard(guardName).redirect();
}check() remains useful where a stale session is harmless and the extra query is not worth it -
for example toggling a "log in / log out" link in a layout.
Multi-Factor Authentication (MFA)
Each guard exposes a TOTP-based MFA helper at req.auth.mfa. The user's model must have an mfa column (the encrypted secret is stored there).
// 1. Start setup — returns a QR code to show the user
const { qrCode, secret, otpauthUri } = await req.auth.mfa.generate({
issuer: 'My App',
label: user.email
});
// 2. Confirm setup with a code from the user's authenticator app
const result = await req.auth.mfa.confirmSetup(req.body.code);
if (result.success) {
// result.recoveryCodes — 8 single-use codes, show them once
}
// 3. At login time, after password check
const valid = await req.auth.mfa.verify(req.body.code);
const viaRecovery = await req.auth.mfa.verifyRecoveryCode(req.body.code);
// State helpers
await req.auth.mfa.enabled(); // boolean — MFA confirmed for this user
await req.auth.mfa.disable(); // clear MFA data (verify the password yourself first)
req.auth.mfa.isPending(); // session flag: password OK, awaiting MFA code
req.auth.mfa.setPending();
req.auth.mfa.clearPending();Sessions
req.session is a per-request session instance.
// Read & write
req.session.set('cart_id', 42);
const cartId = req.session.get('cart_id');
const all = req.session.all();
// Flash messages — available only on the next request
req.session.flash('success', 'Profile updated!');
const message = req.session.getFlash('success');
// CSRF tokens
const token = req.session.getCsrfToken(); // current token (generates one if absent)
req.session.generateCsrfToken(); // force a new token
req.session.verifyCsrfToken(submittedToken); // timing-safe comparison
// Regenerate the session ID (anti-fixation, e.g. after login)
req.session.regenerate();
// Getters
req.session.id;
req.session.createdAt;The session driver (none, file, memory, or redis) is configured in config/session.js.
Validation
import { Validator } from '@nitronjs/framework';
const validation = Validator.make(req.body, {
name: 'required|string|min:2|max:100',
email: 'required|email',
password: 'required|string|min:8|confirmed',
age: 'numeric|min:18',
avatar: 'file|mimes:png,jpg|max:2097152'
});
if (validation.fails()) {
return res.code(422).send({
errors: validation.errors()
});
}
const clean = validation.validated();| Method | Description |
|---|---|
| Validator.make(data, rules) | Build a validator. |
| validation.fails() / validation.passes() | Whether validation failed/passed. |
| validation.errors() | Error messages keyed by field. |
| validation.validated() | The validated subset of the input data. |
email accepts printable ASCII only. MySQL's utf8mb4_unicode_ci treats á[email protected],
a[email protected] and a zero-width-joined spelling as the same row as [email protected], while
JavaScript sees four different strings. Anything the application keys on that value — a rate-limit
counter, a cache key — then opens a separate bucket per spelling while every one of them resolves to
the same account. Internationalised addresses (RFC 6531) need SMTPUTF8 support end to end and are out
of scope.
The same restriction is available on its own as the ascii rule (printable ASCII U+0021 - U+007E;
space and control characters fail). Use it for other values that double as keys — usernames, invite
codes, slugs.
regex takes the pattern as its parameter, for format rules the named rules do not cover:
Validator.make(req.body, {
host: 'string|max:190|regex:^[A-Za-z0-9][A-Za-z0-9.-]*$',
from_name: 'string|max:120|regex:^[^\\r\\n"<>]*$'
});The pattern reaches the rule intact even when it contains : or , (regex:^\\d{2}:\\d{2}$,
regex:^[a-z]{1,3}$). It anchors nothing on its own — write ^/$ if you mean the whole value —
and a non-string fails rather than being coerced. An absent field passes, so it composes with
required. Keeping format rules here rather than in an if below Validator.make keeps a form's
whole contract readable in one table.
Nested fields
A rule key may be a dotted path into a nested object. This is how multilingual payloads are validated without flattening them first:
const validation = Validator.make(req.body, {
'title.tr': 'required|string|max:190',
'title.en': 'required|string|max:190',
'slug.tr': 'required|string|max:120',
'slug.en': 'required|string|max:120'
});Each rule sees the value at that path, so min/max/string apply to the leaf rather than to the
parent object. A missing parent fails required instead of throwing. Error keys keep the dotted form
(errors()['title.en']). Arrays use the * wildcard instead: 'items.*.name': 'required|string'.
Dotted rules bound what a payload must contain, never what it may also contain. 'title.tr' and
'title.en' say nothing about a third key, so a request carrying title[de] passes and — since the
object is normally assigned to a JSON column whole — writes a language the application does not
serve. Giving the parent's array rule a key list closes that side:
const validation = Validator.make(req.body, {
title: 'required|array:tr,en',
'title.tr': 'required|string|max:190',
'title.en': 'required|string|max:190'
});The list is an allow-list, not an exact match: a listed key may be missing (that is what required on
the leaf is for), but an unlisted one fails and the message names it. An absent field passes, so the
rule still composes with required instead of duplicating it, and array without parameters behaves
exactly as before.
The key list belongs to array rather than to a rule of its own, so the type check and the allow-list
cannot come apart: there is no way to bound the keys while forgetting to assert that the value is an
object in the first place. Putting the list on in would be worse still — in compares the value,
so teaching it to inspect keys when handed an object would make its meaning depend on the type of the
incoming data, which the sender chooses. required|in:tr,en would then read like a key check while
accepting the literal string "tr".
Middleware
Middleware is a class with a static async handler(req, res) method. Returning a response (res.send, res.redirect, res.view, or an auth redirect) halts the chain; returning nothing lets it continue.
// app/Middlewares/CheckAge.js
class CheckAge {
static async handler(req, res) {
if (req.query.age < 18) {
return res.code(403).send('Access denied');
}
}
}
export default CheckAge;Register middleware aliases in app/Kernel.js:
import Authentication from './Middlewares/Authentication.js';
import CheckAge from './Middlewares/CheckAge.js';
export default {
routeMiddlewares: {
'auth': Authentication,
'check-age': CheckAge
}
};Reference middleware by its string alias in routes:
Route.get('/admin', AdminController.index).middleware('auth');Middleware can receive a parameter — the framework's auth middleware, for example, accepts a guard name: static async handler(req, res, guardName = 'user').
Real-Time (Socket.IO)
NitronJS ships with first-class Socket.IO support. The API mirrors HTTP routing exactly — same prefix/middleware/name/group chain, same middleware aliases — so you write WebSocket endpoints in the same language as REST endpoints.
Declaring routes
// routes/web.js
import { Route } from '@nitronjs/framework';
import Game from '../app/Sockets/Game.js';
Route.io.prefix('/game').middleware('auth').name('game.').group(() => {
Route.io.on('connect', Game.onConnect);
Route.io.on('room.join', Game.onRoomJoin)
.middleware('game.room.join.form')
.name('room.join');
Route.io.on('round.guess', Game.onRoundGuess)
.middleware('game.round.guess.form')
.name('round.guess');
Route.io.on('disconnect', Game.onDisconnect);
});| HTTP | Socket.IO equivalent |
|---|---|
| Route.get(path, h) | Route.io.on(event, h) |
| Route.prefix('/admin') | Route.io.prefix('/game') → Socket.IO namespace |
| .middleware('auth') | Same — runs at handshake for namespace, per-event for Route.io.on().middleware() |
| .name('admin.') | Same — produces names like game.room.join |
| .group(callback) | Same |
Handler classes
Place handler classes in app/Sockets/. Methods follow an on<EventName> convention parallel to controllers' getX/postX:
// app/Sockets/Game.js
class Game {
static async onConnect (socket) {
// socket.data.user and socket.data.user_id are populated when the
// namespace uses the "auth" middleware — identity is ready for every
// subsequent event handler.
}
static async onRoomJoin (socket, payload, ack) {
// payload was validated by the per-event Form middleware before reaching here
socket.join(payload.code);
socket.data.room_code = payload.code;
ack({ status: 'success', data: { code: payload.code } });
socket.to(payload.code).emit('room.member_joined', {
user_id: socket.data.user_id
});
}
static async onRoundGuess (socket, payload, ack) {
// ... game logic
ack({ status: 'success', data: { colors, attempts_left, won } });
socket.to(socket.data.room_code).emit('round.opponent_progress', {
user_id: socket.data.user_id,
attempts_left
});
}
static async onDisconnect (socket) {
// cleanup
}
}
export default Game;Signatures:
connect/disconnect→(socket). No middleware runs (lifecycle events).- All other events →
(socket, payload, ack).payloadis whatever the client emitted,ackis the optional acknowledgement callback the client passed.
Form middleware for events
Per-event validation works the same as HTTP — write a Form middleware class, register it as an alias in Kernel.js, attach it via .middleware(). Only the signature differs (no req/res; socket/payload/ack):
// app/Middlewares/Game_Round_Guess_Form.js
import { Validator } from '@nitronjs/framework';
class Game_Round_Guess_Form {
static async handler (socket, payload, ack) {
const validate = Validator.make(payload, {
word: 'required|string|min:5|max:5'
});
if (validate.fails()) {
ack({ status: 'failed', message: 'A valid 5-letter word is required.' });
return false; // halt the chain, handler not called
}
}
}
export default Game_Round_Guess_Form;Returning false halts the chain. Anything else (including undefined) lets the handler run.
Namespace middleware (the one attached at Route.io.prefix(...).middleware(...)) runs once during handshake. The same HTTP middleware classes (Authentication, Guest, your own) work without modification — the framework adapter converts the (req, res) signature into Socket.IO's handshake context. If your middleware sends a response or redirects, the framework rejects the handshake.
Identity (socket.data.user)
When a namespace's middleware chain includes the auth alias and the user is authenticated, the framework attaches the loaded User model:
socket.data.user // User model instance
socket.data.user_id // Convenience: user.idThis happens once at handshake, so event handlers don't need to re-resolve the user on every emit.
Native Socket.IO API
Inside handlers, all of Socket.IO's native API is available unchanged:
socket.join(roomCode); // join a room
socket.leave(roomCode); // leave
socket.to(roomCode).emit('event', { ... }); // broadcast to room (excludes sender)
socket.broadcast.emit('event', { ... }); // broadcast to namespace (excludes sender)
socket.emit('event', { ... }); // send to this socket only
socket.data.x = 'value'; // per-connection state
socket.rooms; // Set of rooms this socket is in
socket.disconnect(true); // force closePush from outside a handler — Sockets helper
Use Sockets for handler-free pushes (timers, background jobs, controllers):
import { Sockets } from '@nitronjs/framework';
Sockets.broadcast('/game', 'ABC234', 'round.started', { round_number: 3 });
// Or get the namespace and use Socket.IO's API directly
const ns = Sockets.namespace('/game');
ns.to('ABC234').emit('round.ended', { winner_id: 42 });
// Inspect — list connected sockets in a room
const sockets = await Sockets.in('/game', 'ABC234').fetchSockets();
console.log(`${sockets.length} player(s) online`);Client-side
Use the official socket.io-client. The server is websocket-only (HTTP long-polling is not supported with the Fastify attachment), so clients must pass transports: ["websocket"] — otherwise the default polling handshake 404s and the connection never establishes:
import { io } from 'socket.io-client';
const socket = io('/game', { withCredentials: true, transports: ['websocket'] });
socket.on('connect', () => {
socket.emit('room.join', { code: 'ABC234' }, (resp) => {
console.log(resp); // { status: 'success', data: { code: 'ABC234' } }
});
});
socket.on('round.opponent_progress', ({ user_id, attempts_left }) => {
// ... update UI
});
// Acknowledgement = synchronous request/response over WebSocket
const resp = await new Promise(resolve =>
socket.emit('round.guess', { word: 'kalem' }, resolve)
);HMR
In development, changes to files under app/Sockets/ and to any middleware they use are picked up on the next event reception — no server restart. Existing connections stay open; only the dispatched handler is re-imported from disk. This works for event handlers and event middleware; namespace middleware changes (the ones declared at Route.io.prefix().middleware() time) still require routes/web.js to reload, which the dev server handles automatically.
Backwards compatibility
If your app never calls Route.io.on(), the Socket.IO server is not booted. No port is opened, no memory is held, no behavior changes. WebSocket support is fully opt-in.
Localization (i18n)
Translation files live in resources/langs/. Each locale is either a single JSON file (tr.json) or a folder of namespaced files (tr/messages.json).
// resources/langs/en.json
{
"welcome": "Welcome",
"greeting": "Hello :name",
"cart": {
"items": "{count} item|{count} items"
}
}Translate with the global __() function (or its alias lang()) — available in views, controllers, and client code:
__('welcome') // "Welcome"
__('greeting', { name: 'Burak' }) // "Hello Burak"
__('cart.items', { count: 1 }) // "1 item"
__('cart.items', { count: 5 }) // "5 items"Read the current request locale with the global locale() function — available everywhere (controllers, middleware, server views, and client-side code). It is hydration-safe: the value is identical on the server and after client hydration.
locale() // "tr"
// Pass the current locale to a localized route (e.g. routes under /:locale)
route('cms.dashboard', { locale: locale() }) // => "/tr/cms"
<html lang={locale()}>The Lang class is available for server-side use:
import { Lang } from '@nitronjs/framework';
Lang.get('greeting', { name: 'Burak' });
Lang.has('welcome'); // boolean
Lang.locale(); // current locale
Lang.setLocale('tr'); // change locale for the current requestLang.setLocale() changes the locale of the request that is running — the right tool for a middleware that reads a cookie or a URL segment. When the text is for someone else — a mail to a passenger who reads another language, a job that runs outside any request — scope it with Lang.withLocale() instead. The callback runs with that locale active (every __(), Lang.get() and rendered view inside it follows), and the surrounding locale is back untouched when it returns. Mail.queue() renders the body at queue time, so this is how a queued mail gets the recipient's language rather than the operator's:
await Lang.withLocale(complaint.language, () =>
Mail.to(complaint.passenger_email)
.subject(Lang.get('mail.reply.subject', { number: complaint.number }))
.view('Mail/Reply', data)
.queue()
);A malformed locale throws instead of rendering in the wrong language.
- Dot notation drills into nested keys:
cart.items. - A
{count}parameter triggers pluralization on|-separated values. :paramand{param}placeholders are both replaced from the params object.- An unresolved key returns the key itself, so missing translations are obvious.
- In dev mode, language files hot-reload on change.
import { Mail } from '@nitronjs/framework';
await Mail.from('[email protected]')
.to('[email protected]')
.subject('Welcome!')
.html('<h1>Hello</h1>')
.attachment({ filename: 'file.pdf', path: '/path/to/file.pdf' })
.send();
// Using a view as the email body
await Mail.to('[email protected]')
.subject('Welcome!')
.view('emails/welcome', { name: 'Alice' })
.send();
// BCC several recipients (hidden from each other) — e.g. notify every admin
await Mail.to(process.env.MAIL_USERNAME)
.bcc(['[email protected]', '[email protected]'])
.subject('New reservation')
.view('emails/notice', { code: 'WL-1234' })
.send();
// Send on behalf of a tenant: `from` stays the authenticated account,
// `replyTo` is where the recipient's answer should actually go
await Mail.to(member.email)
.from('Zeynep PT <[email protected]>')
.replyTo('[email protected]')
.subject('Kaydın onaylandı')
.view('emails/notice', { name: member.name })
.send();
// Render the body to static HTML without sending — e.g. for a live preview
const html = await Mail.to(member.email)
.view('emails/notice', { name: member.name })
.render();bcc(addresses) accepts a single address or an array. replyTo(address) sets the Reply-To header — SMTP providers reject or spam-file a From that is not the authenticated account, so a multi-tenant application sends every message from one platform address and uses replyTo to make the reply reach the right person. SMTP credentials are read from the MAIL_* environment variables, or from the object handed to send(callback) when they live elsewhere (a settings table, say). render() runs the same view-rendering step as send() but returns the HTML string instead of dispatching an email — useful for previewing a template before it goes out.
Queueing a message
send() waits for the SMTP round trip, so a request that mails one person is slow and a request that mails a hundred is a timeout. queue() is the same builder with a different ending: it writes one mail job to the Queue and returns its id immediately, and the worker inside the server sends it in the background.
await Mail.from('[email protected]')
.to(user.email)
.subject('Welcome!')
.view('Mail/Welcome', { name: user.name })
.queue();
// An attachment travels as a file path, so the worker reads it at send time
await Mail.from('[email protected]')
.to(user.email)
.subject('Your report')
.view('Mail/Report', { month: '2026-09' })
.attachment({ filename: 'report.pdf', path: '/storage/app/public/upload_files/report.pdf' })
.queue();The body is rendered at queue() time, not at send time. That is what makes the row self-contained: the message goes out with the data as it was when the event happened, a retry sends exactly the same bytes, and the worker never touches application models.
An attachment obeys the same rule, which is why it has to be a path. The job stores the file's filename and path and nothing else, and the worker opens that path on every attempt — write the file once, never edit it in place, and every retry sends identical bytes. Attachment content held in memory (content: carrying a Buffer or a string) has no column it could survive in, and a calendar invite needs the alternatives structure only send() builds, so queue() throws on either rather than dropping it silently:
Mail.queue() carries an attachment only as a file path. Attachment content held in memory and calendar invites must go through send().Only filename and path are stored, whatever else the attachment object carried. The payload is read back out of the database and handed to the transport, so a stray content, href or raw on the caller's object would be a nodemailer option smuggled through a column — it is dropped at queue() time instead.
The mail handler holds one pooled SMTP connection for the whole batch instead of opening one per message, and hands it back when the batch ends. Everything else — the timer, the retry ladder, the give-up point — belongs to the Queue and is described there.
Queue
Work that is slow, flaky, or simply not the caller's business belongs in the background. Queue.push() writes one row and returns; a timer inside the running server picks it up and hands it to the handler registered for its type.
import { Queue } from '@nitronjs/framework';
await Queue.push({
type: 'mail',
target: user.email,
label: 'Welcome',
payload: message
});Four fields carry a job. type decides which handler runs it. target is who or what the work is for — an address, a phone number, a URL — and label is one human-readable line; both are ordinary indexed columns, so an admin screen can list and search jobs without digging into JSON. payload is everything the handler needs, and it must be resolved at push time: put the rendered message in it, not a row id to look up later. That is what makes a retry repeat the same work rather than whatever the database happens to say an hour on.
Handlers
A handler is any object with an async handle(payload). It signals failure by throwing — the worker catches, records and reschedules. An optional finish() runs once after the batch, for a handler that holds something open across it (the built-in mail handler keeps one pooled SMTP connection that way).
// app/Jobs/Send_SMS.js
class Send_SMS {
static async handle(payload) {
const response = await fetch(GATEWAY, { method: 'POST', body: JSON.stringify(payload) });
if (!response.ok) {
throw new Error(`SMS gateway answered ${response.status}`);
}
}
}
export default Send_SMS;Types are declared in Kernel.js beside the route middlewares, so a new kind of background work is one file plus one line:
// app/Kernel.js
export default {
routeMiddlewares: { ... },
jobHandlers: {
'sms': Send_SMS
}
};Queue.handle(type, handler) registers one imperatively, which is mostly useful in tests. The framework registers mail itself.
The worker
The HTTP server starts the worker right after the database and stops it on shutdown, so there is no second process to deploy and no long-lived worker holding yesterday's code in memory. Each tick claims the oldest due rows and runs them in order.
Work that throws is put back with a growing wait — 1, 5, 15, 60, then 360 minutes — and becomes failed once max_attempts is spent, after which only an explicit requeue moves it. A row whose type has no handler fails immediately instead: retrying could never help, and leaving it pending would let it chew through every batch forever.
Finished rows are kept, so jobs doubles as the record of what the application has done in the background: type, target, label, attempts, last error, finish time.
// config/queue.js
export default {
enabled: true,
interval_seconds: 10,
batch: 20,
max_attempts: 5
};enabled: false never starts the timer, which is what a test suite wants — call Queue.drain() there instead and assert on what came out.
The jobs table is the framework's own. It ships as a framework migration, so njs migrate creates it ahead of the project's own migrations: there is nothing to copy into database/migrations/, and nothing to keep in step when its columns change. A project that created the table itself before this shipped keeps working untouched — the framework migration only creates what is missing.
Reading the queue
Job is that table as a model, so the admin screen listing outgoing mail is an ordinary query, and an application never declares the framework's table in its own app/Models/.
import { Job } from '@nitronjs/framework';
const failed = await Job
.where({ type: 'mail', status: 'failed' })
.orderBy('created_at', 'desc')
.limit(50)
.get();Requeueing is the one write an application makes here. Put status back to pending, clear attempts and pull available_at up to now, and the next tick picks the row up again.
const row = await Job.find(id);
row.status = 'pending';
row.attempts = 0;
row.last_error = null;
row.available_at = DateTime.toSQL();
await row.save();Storage
import { Storage } from '@nitronjs/framework';
await Storage.put(file, 'upload_files', 'image.jpg');
const buffer = await Storage.get('upload_files/image.jpg');
await Storage.delete('upload_files/old.jpg');
await Storage.move('upload_files/a.jpg', 'upload_files/b.jpg');
Storage.exists('upload_files/image.jpg');
Storage.url('upload_files/image.jpg'); // => "/storage/upload_files/image.jpg"Pass true as the last argument to get/put/delete/move/exists to operate on private storage (storage/app/private/), which is not web-accessible.
Utilities
Encryption
import { AES } from '@nitronjs/framework';
const token = AES.encrypt({ userId: 1, expires: '2025-12-31' });
const data = AES.decrypt(token); // Returns false on tamperHashing
import { Hash } from '@nitronjs/framework';
const hashed = await Hash.make('password123');
const valid = await Hash.check('password123', hashed);The bcrypt cost factor is configured in config/hash.js.
Logging
import { Log } from '@nitronjs/framework';
Log.info('User registered', { userId: 1 });
Log.error('Payment failed', { orderId: 123 });
Log.warning('Slow query', { ms: 1200 });
Log.debug('Query executed', { sql: '...' });DateTime
Use DateTime for all server-side date and time work — never new Date().
import { DateTime } from '@nitronjs/framework';
DateTime.toSQL(); // "2026-05-14 10:30:00" (now, SQL format)
DateTime.toSQL(timestamp); // a specific timestamp in SQL format
DateTime.getDate(timestamp, 'Y-m-d H:i'); // formatted date string
DateTime.getTime(sqlDateTime); // SQL datetime → millisecond timestamp
DateTime.addDays(7); // 7 days from now, SQL format
DateTime.addHours(3);
DateTime.addMinutes(30);
DateTime.subDays(7);
DateTime.subHours(2);
DateTime.subMinutes(15);Faker
Built-in fake data generator for seeders and testing:
import { Faker } from '@nitronjs/framework';
Faker.fullName(); // "John Smith"
Faker.email(); // "[email protected]"
Faker.phoneNumber(); // "+1 555 0142"
Faker.sentence(); // "Lorem ipsum dolor sit amet."
Faker.paragraph(); // multi-sentence text
Faker.int(1, 100); // 42
Faker.float(0, 1, 2); // 0.37
Faker.boolean(); // true
Faker.uuid(); // "550e8400-e29b-41d4-a716-446655440000"
Faker.creditCard(); // Luhn-valid card number
Faker.hexColor(); // "#a3f29c"
Faker.city(); // "Istanbul"
Faker.companyName(); // "Acme Inc."
Faker.imageUrl(640, 480); // placeholder image URL
Faker.arrayElement(['a', 'b', 'c']);
Faker.oneOf('x', 'y', 'z');String Utilities
import { Str } from '@nitronjs/framework';
Str.slug('Hello World'); // "hello-world"
Str.camel('user_name'); // "userName"
Str.pascal('user_name'); // "UserName"
Str.snake('userName'); // "user_name"
Str.kebab('userName'); // "user-name"
Str.title('hello world'); // "Hello World"
Str.ucfirst('hello'); // "Hello"
Str.random(32); // random string
Str.uuid(); // a UUID
Str.limit('Long text here', 9);// "Long text..."
Str.plural('post'); // "posts"
Str.singular('posts'); // "post"
Str.contains('hello', 'ell'); // true
Str.startsWith('hello', 'he'); // trueGlobal Functions
These functions are available everywhere — controllers, views, and client-side code — without an import:
| Function | Description |
|---|---|
| route(name, params?, query?) | Generate a URL for a named route. |
| csrf() | The current request's CSRF token. Use it in forms and fetch headers. |
| __(key, params?) | Translate a key with the current locale. |
| lang(key, params?) | Alias of __(). |
| request() | The current request object (Server Components only). Exposes path, method, query, params, headers, cookies, ip, isAjax, locale, session, auth. |
// In a form
<input type="hidden" name="_csrf" value={csrf()} />
// In a fetch call
fetch('/api/posts', {
method: 'POST',
headers: { 'x-csrf-token': csrf() }
});
// In a Server Component
const tab = request().query.tab || 'general';CLI Commands
# Development
npm run dev # Start dev server with HMR
npm run build # Build for production
npm run start # Start production server
# Database
npm run migrate # Run migrations
npm run migrate:safe # Run migrations after an automatic DB backup
npm run migrate:fresh # Fresh migration
npm run migrate:fresh:seed # Fresh migration + seed
npm run migrate:rollback # Rollback last batch
npm run migrate:status # Show migration status
npm run seed # Run seeders
npx njs db:backup # Manual DB backup for the current deploy target
npx njs db:backups # List all DB recovery points
npx njs db:restore <id> # Restore the DB from a backup (takes a pre-restore backup)
# Code Generation
npm run make:controller <name>
npm run make:model <name>
npm run make:middleware <name>
npm run make:migration <name>
npm run make:seeder <name>
npm run make:socket <name>
# Diagnostics
npx njs doctor # Framework health check (auto-fixes safe drift)
npx njs deploy:doctor # Deploy health check (cascades into doctor)
# Deployment
npx njs deploy:init # Scaffold CI/CD files (workflow YAML, deploy.config.js)
npx njs deploy:runner # Install a self-hosted GitHub Actions runner (--allow-root to run as root)
npx njs deploy:runner:remove # Uninstall the self-hosted runner from a server
npx njs deploy:rollback # Restore a previous snapshot interactively (on a server)
# Utilities
npm run storage:link # Create storage symlink
npx njs key:generate # Write a fresh APP_KEY into .env
npx njs key:generate --force # Overwrite an existing APP_KEY (invalidates hashes/sessions/encrypted data)Project Structure
my-app/
├── app/
│ ├── Controllers/ # Request handlers
│ ├── Middlewares/ # Custom middleware
│ ├── Models/ # Database models
│ ├── Sockets/ # Socket.IO handler classes (optional)
│ └── Kernel.js # Middleware alias registry
├── config/ # Configuration files
│ ├── app.js
│ ├── auth.js
│ ├── database.js
│ ├── hash.js
│ ├── server.js
│ └── session.js
├── database/
│ ├── migrations/ # Database migrations
│ └── seeders/ # Database seeders
├── public/ # Static assets
├── resources/
│ ├── css/ # Stylesheets
│ ├── langs/ # Translation files
│ └── views/ # React components (TSX)
├── routes/
│ └── web.js # Route definitions
├── storage/ # File storage (logs, sessions, uploads, snapshots)
├── deploy.config.js # Deployment targets (created by deploy:init)
└── .env # Environment variablesCSS & Tailwind
Put your CSS files in resources/css/. Tailwind CSS v4 is automatically detected and processed.
/* resources/css/global.css */
@import "tailwindcss";Import in your .tsx files using the @css alias:
import "@css/global.css";Configuration
Accessing config values
import { Config } from '@nitronjs/framework';
const appLocale = Config.get('app.locale');
const sessionDriver = Config.get('session.driver', 'file'); // with a defaultConfig.get(key, default?) reads from the files in config/. The first key segment is the file name (app → config/app.js), the rest is the path into that file's exported object.
Config files
| File | Configures |
|---|---|
| config/app.js | locale, fallback_locale, timezone, and the csp (Content Security Policy) whitelist. |
| config/auth.js | defaults.guard and the guards map (provider model, login identifier, home / redirect routes). |
| config/database.js | connections — per-driver settings (charset, collation, connection pool). |
| config/session.js | driver, lifetime, cookieName, the cookie options, and csrf settings (which methods are protected, the token/header field names, route exceptions). |
| config/server.js | engine (which HTTP engine runs the server), web_server (body limit, multipart upload limits and security), cors, and log settings. |
| config/hash.js | salt_rounds — the bcrypt cost factor. |
HTTP engine
config/server.js names the HTTP engine the server runs on:
export default {
engine: 'fastify',
// ...
};NITRON_ENGINE overrides it for a single process, which is how a deployment tries one
engine on one machine without editing the file:
NITRON_ENGINE=fastify npx njs startAvailable engines: fastify (the default). A name that is not on that list stops the
boot and prints the names that are — a typo must not quietly start a server on an
engine nobody chose.
Environment variables
Configuration that varies per environment lives in .env:
APP_NAME=my-app
APP_KEY=
APP_URL=http://localhost
APP_PORT=3000
FILESYSTEM_DRIVER=disk
# Driver: mysql | postgresql | mongodb | none
DATABASE_DRIVER=none
DATABASE_HOST=127.0.0.1
DATABASE_PORT=3306
DATABASE_NAME=my-app
DATABASE_USERNAME=root
DATABASE_PASSWORD=
MAIL_HOST=
MAIL_PORT=
MAIL_USERNAME=
MAIL_PASSWORD=
MAIL_SECURE=
REDIS_HOST=127.0.0.1
REDIS_PORT=63