
The Node.js framework that knows what your data is
Install
From nothing to a running application
Section titled “From nothing to a running application”npm install -g henrihenri new my-appcd my-app && henri serverA 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
A whole application in three files
Section titled “A whole application in three files”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.
module.exports = { '/': 'tasks#index', 'resources tasks': 'tasks',};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('/'); },};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
It knows what your data is
Section titled “It knows what your data is”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.
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.
nameandphoneare masked by name in every log line and every recorded error, next to the substring matchfilterParametersalready does.phoneis 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.taxIdis AES-256-GCM in the column, under a keyring that rotates without movingupdatedAt. A read that fails throws rather than answeringnull, and an encrypted field is personal unless the model says otherwise.henri privacy:exporthands a person every row held about them andprivacy:eraseremoves them —userIdis what makes this row theirs, andonErase: '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
Membercrosses 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.
henri privacy # the map: what is held about a person, and wherehenri privacy:export ada@example.com # everything held about themhenri privacy:erase ada@example.com # remove them, and leave a receipthenri retention:sweep --yes # the cron line; or a recurring jobhenri trail:verify # the chain, and where it breakshenri audit # the ASVS requirements your files answerNone 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
Built to be driven by one
Section titled “Built to be driven by one”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
What henri does not do
Section titled “What henri does not do”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:
onRenderruns last, so an inliner cannot leak a style attribute into the plain text part.henri auditis 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:statusreports the drift for a person to review.
npm install -g henrihenri new my-appGetting 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.
