Skip to content
The henri logo

The Node.js framework that knows what your data is

henri is Rails-shaped — models, controllers, routes, real ORMs, generators, hot reload. It also knows which of your columns are about a person, how long a record is kept and which customer a row belongs to. Those are marks on the model, not a project for next quarter.

Install

Terminal window
npm install -g henri
henri new my-app
cd my-app && henri server

A new application is a Drizzle store on sqlite in .henri/app.db: nothing to install, migrations from the first day, and a database you can deploy rather than one you have to leave behind. --adapter postgresql|mysql|mssql|mongoose|disk picks another one, and the sample resource, the dependencies and the configuration follow it.

  • 1.2.0, every package

  • Twenty packages, one version

  • Node 22 or newer

  • Inertia + React 19, or Next.js

  • MIT

The shape

If you have written Rails, you already know where everything goes. Routes expand into actions, controllers hand their data straight to the pages, and there is no API layer in between to write.

config/routes.js
module.exports = {
'/': 'tasks#index',
'resources tasks': 'tasks',
};
app/controllers/tasks.js
module.exports = {
index: async (req, res) => {
res.render('/tasks', { data: { tasks: await Task.find() } });
},
create: async (req, res) => {
await Task.create(req.permit('name'));
res.redirect('/');
},
};
app/views/pages/tasks/index.jsx
import { useHenri } from '@usehenri/inertia';
export default function Tasks() {
const { data } = useHenri();
return (
<ul>
{data.tasks.map((task) => (
<li key={task.externalId}>{task.name}</li>
))}
</ul>
);
}

The same route answers JSON when a client asks for it: res.collection(tasks) is HAL with _links built from the route helpers and filtered by the reader’s roles, then by the policy of each record — so a visitor who may not delete a task never sees the link that would. henri generate scaffold Task name:string! writes all three of these plus the model, the form and the four other pages — reading the model file back as it goes, so a required column gets a required input and an enum column gets a <select> of its values.

Where henri is different

Your application knows which of its columns are about a person. Nothing else does — not the logger, not the serializer, not whoever has to answer an access request three years from now. henri moves that knowledge into the schema, where it can act on its own.

app/models/Member.js
module.exports = {
options: {
personal: { onErase: 'anonymize' },
retention: { action: 'delete', after: '18mo', from: 'leftAt' },
tenant: true,
versioned: true,
},
schema: {
userId: { references: { model: 'User' }, required: true, type: 'integer' },
name: { personal: true, type: 'string' },
phone: { personal: { expose: false }, type: 'string' },
taxId: { encrypted: true, type: 'string' },
leftAt: { type: 'date' },
},
};

That is the whole declaration. Everything below follows from it, with no where to write and no where to forget.

  • name and phone are masked by name in every log line and every recorded error, next to the substring match filterParameters already does.

  • phone is dropped from everything henri serializes — res.render, res.resource, res.collection, res.json, an embedded relation, a CSV export, a stream frame — at every depth, unless the answer names it.

  • taxId is AES-256-GCM in the column, under a keyring that rotates without moving updatedAt. A read that fails throws rather than answering null, and an encrypted field is personal unless the model says otherwise.

  • henri privacy:export hands a person every row held about them and privacy:erase removes them — userId is what makes this row theirs, and onErase: 'anonymize' is this model’s answer: the row stays and its personal fields are cleared, so the count stays true. Every erasure leaves a receipt holding an HMAC of the identity rather than the identity.

  • A member who left eighteen months ago is deleted by the retention sweep — which writes nothing until that rule’s token is approved in the configuration, so no rule starts deleting because somebody edited a model.

  • Every change is a row in henri_versions: the fields, the old value and the new one, who did it and the request id. A mass write is refused rather than recorded once for a hundred rows.

  • No query for a Member crosses a tenant, and one with no tenant in scope is refused rather than answered with everybody’s rows — which is what makes a job, a seed or a console session fail loudly instead of leaking.

And what henri did itself is in a chain. The access trail records every export, every erasure and every sweep, plus — when you ask for it — every answer henri serialized. Each entry carries a sequence number under a unique index and a hash of the entry before it, so a row edited or removed breaks the chain and henri trail:verify says where.

Terminal window
henri privacy # the map: what is held about a person, and where
henri privacy:export ada@example.com # everything held about them
henri privacy:erase ada@example.com # remove them, and leave a receipt
henri retention:sweep --yes # the cron line; or a recurring job
henri trail:verify # the chain, and where it breaks
henri audit # the ASVS requirements your files answer

None of this is compliance, and henri does not claim to make you compliant. It is the machinery a compliance answer gets written from — and henri audit names the requirement each finding maps to, so the engineering half and the paperwork half are the same conversation.

The framework

Everything else you would have installed anyway

Section titled “Everything else you would have installed anyway”

Real ORMs, and real migrations

Drizzle on sqlite, PostgreSQL and MySQL with generated, versioned migrations; Mongoose on MongoDB; Sequelize under SQL Server. One model format for all of them.

A generated migration is read back before it runs, and a production migrate() refuses a dangerous one until its token has been approved.

A JSON API you did not write

HAL answers, declared params, filters, embeds and answers, idempotency keys, rate limits, pagination, and a CSV export that streams on a cursor.

henri openapi writes the OpenAPI 3.1 description from your routes and models without booting — and refuses to describe what a controller writes rather than guessing at it.

Background work that survives a restart

A database-backed queue with retries, a dead letter queue, recurring jobs, per-job concurrency limits and batches. No second server to run.

Outbound webhooks sign with Standard Webhooks, and every address is checked when the request is made — then the socket is pinned to the address that was checked.

Users, roles and record-level rules

argon2id or bcrypt, sessions in your database, double-submit CSRF with an origin check, sign-up, password reset, confirmation, and sign-in with somebody else’s identity provider.

Policies fail closed: no policy, no rule for the action and a rule that threw all mean no, and only the boolean true allows.

Real time, without a second protocol

res.stream() is a route: server-sent events on the http server henri already runs, through the same router, session, role guard and policies as everything else.

The policy is asked at subscribe time and again before every event, because a stream is one decision answered from for hours.

The day-to-day

Generators that read your model files back, tests on Vitest with factories and a mail inbox, a console whose --sandbox rolls back on exit, and hot reload for controllers, models, routes, workers and configuration.

Mail with previews, caching, feature flags, uploads, i18n, time zones, maintenance mode, OpenTelemetry and GraphQL are in the box or one install away.

Coding agents

None of it is required — a person typing commands gets the same framework — but the conventions are written where an agent will read them, and the failures carry codes instead of prose.

An MCP server

henri mcp exposes the routes, the models, the configuration, the OpenAPI document, the generators, the tests, doctor and audit — plus what the database actually holds, read from its catalogue rather than from the model files.

AGENTS.md, generated

Not a template with your name in it: it is read off your application, held to 150 lines because it loads on every task, and regenerating replaces only what is between the markers. henri new writes it.

Types it can read

Every published package ships TypeScript declarations, and .henri/types.d.ts is generated from your application: an interface per model, an enum column as a union of its values, and every path helper your routes expand to.

254 error codes

One catalogue across the framework, the adapters, the queue, the view engines and the command line, in 39 areas — each code with what it means, what usually causes it and how to fix it.

The documentation, at your version

The guides ship inside @usehenri/core, so henri docs and the MCP guide tool answer for the henri your application runs rather than for whatever this site says today.

Something to check it against

henri doctor reports what an application cannot have meant; henri audit weighs what it chose, against ASVS 4.0.3. Both answer --json, and so does everything else.

Before you install, not after

The guides argue each of these where they live. The list is here because it is short, and because you should have it now.

  • A broadcast reaches one process. Server-sent events run on the process that accepted the connection, and there is no cross-process fan-out yet. Behind two workers half the subscribers are not told — and nothing errors. henri warns when the environment gives it evidence of a second process, which cannot see a second machine.

  • There is no queue dashboard, and there will not be one. A job’s arguments are the customer’s address and the invoice being rebuilt: the data the personal marks exist to keep out of answers. A read-only page behind your own policy is the answer, and henri.jobs.* is what you build it from.

  • The Next.js engine is frozen on the pages router. It is supported and keeps getting fixes, but it does not follow Next.js into the app router. New applications get Inertia, and the views guide has the mapping between the two.

  • henri inlines no CSS into mail. The honest version of that is a CSS parser, a selector engine and an html serializer, and a mangled mail cannot be fixed after it is sent. What henri owns is the seam: onRender runs last, so an inliner cannot leak a style attribute into the plain text part.

  • henri audit is not a code analysis. It reads your settings, your marks, your declarations and four shapes. There is no dataflow, so a clean run means your declarations are right. It does not mean the code is.

  • SQL Server gets no migrations. Drizzle has no dialect for it, so an mssql store creates what is missing in development and henri db:status reports the drift for a person to review.

Terminal window
npm install -g henri
henri new my-app

Getting started walks the first application end to end. The guides are the long form — 34 of them, and they argue their decisions rather than listing features. Configuration is every key henri owns.

henri is MIT and built in the open at usehenri/henri. Twenty packages, versioned together at 1.2.0 and published from CI with npm provenance.