Skip to content

Views

Pick a renderer in your configuration. All of them render on the server; the Inertia and React renderers also push updates to the browser while you develop. A view engine is loaded from the application, so its packages must be installed there (henri new does it).

{ "renderer": "inertia" }

henri new writes inertia. henri new --renderer react writes react and scaffolds the Next.js application instead; both render the same pages from the same controllers, and the differences are spelled out under each renderer below.

Whatever the renderer, a controller renders with res.render(route, { data }) and the page receives data, user, paths, query, csrf, localUrl, errors and graphql (see Controllers). Static files go in app/views/public, served at the root.

henri new scaffolds a styled application: Tailwind CSS v4 is wired for the renderer you picked and the sample pages are written with it. app/views/styles/index.css is the whole stylesheet.

app/views/styles/index.css
@import 'tailwindcss' source(none);
@source '../pages/**/*.{js,jsx}';
@source '../components/**/*.{js,jsx}';
@layer base {
:root {
color-scheme: light dark;
}
body {
@apply bg-white text-zinc-900 antialiased dark:bg-zinc-950 dark:text-zinc-50;
}
}

Tailwind v4 has no tailwind.config.js: the theme is CSS. Add @theme { --color-brand: oklch(0.6 0.2 250); } to that file and bg-brand, text-brand and border-brand exist. source(none) turns automatic file detection off, so Tailwind reads exactly the @source globs above and never walks .next/ or dist/; add a line before writing classes somewhere else (@source '../index.html';, @source '../../helpers/**/*.js';).

Dark mode follows the operating system, with no toggle and no class to set: dark: is Tailwind’s default prefers-color-scheme: dark variant, and color-scheme: light dark hands the same preference to the native form controls, the scrollbars and the focus rings. Add @custom-variant dark (&:where(.dark, .dark *)); if you would rather drive it from a class.

How it is compiled depends on the renderer:

Renderer Plugin Wiring
inertia @tailwindcss/vite a plugin merged into app/views/vite.config.mjs; imported by app/views/main.jsx
react @tailwindcss/postcss app/views/postcss.config.mjs; the stylesheet is imported by pages/_app.js

Both work the same in development and in production: henri server compiles the stylesheet on the fly, henri build writes it next to the bundle and the html links it, server-side rendered markup included.

Adding Tailwind to an existing application

Section titled “Adding Tailwind to an existing application”

With the Inertia renderer:

Terminal window
pnpm add tailwindcss @tailwindcss/vite
app/views/vite.config.mjs
import tailwindcss from '@tailwindcss/vite';
import { henriViteConfig } from '@usehenri/inertia/vite';
import { mergeConfig } from 'vite';
export default mergeConfig(henriViteConfig({ views: import.meta.dirname }), {
plugins: [tailwindcss()],
});

Then import './styles/index.css'; from app/views/main.jsx.

With the React renderer:

Terminal window
pnpm add tailwindcss @tailwindcss/postcss
app/views/postcss.config.mjs
export default { plugins: { '@tailwindcss/postcss': {} } };

Then import '../styles/index.css'; from app/views/pages/_app.js.

Nothing in henri depends on Tailwind; the classes are plain strings in the pages. Write your own CSS in app/views/styles/index.css (keep the import, it is the only stylesheet the pages load), then remove tailwindcss and, depending on the renderer, @tailwindcss/postcss with app/views/postcss.config.mjs, or @tailwindcss/vite with the plugin it adds to app/views/vite.config.mjs.

The scaffolded pages, and everything henri generate scaffold writes afterwards, keep carrying Tailwind class names. Without Tailwind they are inert: rewrite the markup, or define the handful of classes you keep in your own stylesheet. sass stays installed, so renaming the file to index.scss (and the import with it) gets you Sass instead, and *.module.scss files next to a page keep working.

The default renderer. It speaks the Inertia.js protocol: your controllers keep rendering routes with res.render(), the page is a React 19 component bundled by Vite, and navigation between pages happens without a full page load. The first visit is server-side rendered; the Inertia client then asks for page objects (JSON) and swaps the component.

What an application gives up by choosing it: Vite is a bundler, not a framework, so there is no file-system routing (config/routes.js is the router), no image or font optimization, no incremental static regeneration and no React Server Components. Everything the browser runs is a client component hydrating a server-rendered page.

{ "renderer": "inertia" }
Terminal window
henri new my-app
# or, in an existing application
pnpm add @usehenri/inertia @inertiajs/react react react-dom vite @vitejs/plugin-react tailwindcss @tailwindcss/vite sass

The engine reads four files from app/views. henri new ships them and the engine creates the missing ones on first boot:

File Role
index.html The html shell. <!--head--> and <!--body--> receive the rendered page.
main.jsx The browser entry: createInertiaApp resolving pages/**/*.jsx with resolvePage().
ssr.jsx The server entry: render(page) resolves to { head, body }.
vite.config.mjs The shared configuration (@usehenri/inertia/vite: React plugin, aliases, builds) merged with the Tailwind plugin.

res.render('/tasks/index', { data }) renders app/views/pages/tasks/index.jsx (res.render('/tasks') finds it too). The page reads what the controller sent through useHenri():

app/views/pages/tasks/index.jsx
import { Form, Link, useHenri } from '@usehenri/inertia';
export default function Tasks() {
const { data, pathFor, getRoute } = useHenri();
return (
<div>
<Form action={pathFor('create_tasks_path')} resetOnSuccess>
{({ errors, processing }) => (
<>
<input name="name" />
{errors.name && <p>{errors.name}</p>}
<button disabled={processing}>add</button>
</>
)}
</Form>
<ul>
{data.tasks.map((task) => (
<li key={task.externalId}>{task.name}</li>
))}
</ul>
<Link href={getRoute('home_main_path')}>home</Link>
</div>
);
}

useHenri() returns the same keys as the React renderer’s withHenri (data, user, paths, csrf, localUrl, flash, errors, graphql, pathFor, getRoute, fetch, hydrate) plus query. They are the props of the Inertia page object, so usePage().props from @inertiajs/react holds the raw values. hydrate() is an Inertia partial reload of data; fetch(target, payload) is a JSON request outside the Inertia lifecycle (GET data goes in the query string) that sends the CSRF header and rejects with an error carrying status and response.

Forms submit through Inertia’s router: a controller answers with a redirect (res.redirect('/tasks'); the engine turns it into a 303 after PUT, PATCH and DELETE) and the client lands on the next page. To show validation errors, render the page again after res.inertia.errors({ name: 'required' }): they arrive in errors. res.inertia.location(url) redirects to an external url.

Form wraps Inertia’s form component: action accepts a pathFor() result, the method defaults to POST, and a hidden _csrf field carrying the page’s token is added to its children (csrf={false} skips it, csrf="..." overrides it), so submissions pass henri’s CSRF check in an application with a user model; fetch() sends the X-CSRF-Token header, and the engine sets an XSRF-TOKEN cookie that Inertia’s client echoes as X-XSRF-TOKEN (an alias henri accepts), so visits made directly with router.post() or useForm().post() pass as well. Link, Head, router, usePage and useForm are re-exported from @inertiajs/react. assets, components, helpers and styles resolve to the matching folders under app/views; global stylesheets are imported from main.jsx, which is where the Tailwind stylesheet is imported.

Options go under the inertia key of your configuration: ssr: false renders everything in the browser (the page object is still embedded in the html), id changes the root element id (app), entry, ssrEntry and template rename the three files above. Hot module replacement in development rides on henri’s http server, no second port; in development a page that fails to render on the server answers a 500 with the stack, in production it falls back to client rendering.

henri server --production builds the client (app/views/dist/client, with a manifest whose hash is the Inertia asset version) and the server bundle (app/views/dist/ssr) on the first boot when the manifest is missing, or when --force-build is given, then serves dist/client with immutable cache headers. henri build runs the two Vite builds on their own, without booting henri.

The caution above says henri does not follow Next.js into the app router. This is the other half of that sentence: what happens to an application that is on this engine when the pages router finally goes.

Deprecated is not removed. Next.js 16 ships the pages router and this engine runs on it. next is a peer dependency, which means the application owns the version: nothing henri does can move it, and nothing Next.js does reaches an application that has not upgraded.

What henri commits to. The React engine keeps working, and keeps getting fixes, on the Next.js majors that carry the pages router. It is frozen in the sense that it gains no app-router features, not in the sense that it is abandoned.

When the pages router is removed from a Next.js major, henri does not chase it. An application then has two answers, and neither of them is a surprise henri springs on the day:

  1. Stay. Pin the last Next.js major that has the pages router. This needs nothing from henri — it is a version in the application’s own package.json — and it is the right answer for an application that is finished, or one whose next change is a rewrite anyway. What it costs is the rest of that major’s security fixes, on the application’s own clock.
  2. Move to Inertia. There is no automatic conversion and there will not be one: the two engines put the data in different places, so the pages are rewritten. What that actually means is small enough to write down:
React engine Inertia
export default withHenri(Page) a plain component; const { data } = useHenri()
props on the page component useHenri(), at any depth
next/link Link from @usehenri/inertia
@usehenri/react/forms (Form, Input, Select, useForm) Form from @usehenri/inertia
router.push('/tasks') after a submit the controller redirects; Inertia follows it
a failed write is a 422 the form reads field by field the controller calls res.inertia.errors() and renders the form page again
getRoute / pathFor getRoute / pathFor, unchanged
.js pages under app/views/pages .jsx pages in the same place

What does not change: config/routes.js, the controllers’ res.render() calls, the models, the policies, the tests that go through @usehenri/testing. The router is henri’s in both engines, which is why the move is a view-layer rewrite and not a port.

What would change this decision. One thing: an app-router equivalent of the contract this engine rests on — a supported way for a server component to read what henri attached to the request, the way withHenri reads req._henri. Passing it through the url is not that; it would put a controller’s data in a place the client chooses. If that seam appears, the calculation is worth redoing, and until it does, an app-router engine would be a second way to write a henri application that henri could not hold to the same promises.

Choose it anyway when you want what Next.js gives you and Vite does not: next/image and next/font, the file-system page fallback (a GET no route matches still renders a page of the same name), the Next.js plugin ecosystem, or an existing pages-router application you are moving onto henri. What you give up is the app router, server components and streaming: a page under app/views/app would bypass withHenri and the controllers, and the engine warns when that directory exists.

Next.js (16, pages router, Turbopack) renders the pages in app/views/pages and injects the data sent by your controllers.

{ "renderer": "react" }

Install the peer dependencies in your project (henri new --renderer react already does):

Terminal window
pnpm add @usehenri/react next react react-dom tailwindcss @tailwindcss/postcss sass

Two small files live next to your pages. henri new --renderer react ships them and the engine creates them on first boot when they (or their .mjs/.ts alternatives) are missing:

app/views/next.config.js
module.exports = require('@usehenri/react/engine/conf');
app/views/jsconfig.json
{ "compilerOptions": { "baseUrl": "." } }
app/views/pages/tasks/index.js
import Link from 'next/link';
import withHenri from '@usehenri/react';
const Tasks = ({ data: { tasks = [] }, getRoute }) => (
<ul>
{tasks.map((task) => (
<li key={task.externalId}>
<Link href={getRoute('show_tasks_path', task.externalId)}>
{task.name}
</Link>
</li>
))}
</ul>
);
export default withHenri(Tasks);

withHenri wraps a page component. On the server it reads what henri attached to the request (req._henri, never the url); on client-side navigation it fetches the same url as JSON. The page receives these props, and nested components read the same values from useHenri():

Prop Description
data What the controller passed to res.render(), or the GraphQL result. Follows navigation, and hydrate() overrides it until the next navigation.
user The logged-in user (id, email, roles, the config.user.public fields) or null
paths The routes this user may call, keyed like index_tasks_path
csrf The CSRF token, or null without a user model
errors GraphQL errors of the render, or null
flash The flash messages of this render, by type ({ notice: ['Saved'] })
graphql { endpoint, query } of the render
localUrl The url the server listens on
error The error of the last hydrate() or client-side fetch, or null
pathFor pathFor('show_tasks_path', id) builds a url from a route name (see below)
getRoute getRoute('show_tasks_path', id) returns the url as a string, or 'route-not-found'
fetch fetch(target, body) calls a route and resolves with the parsed answer (see below)
hydrate Fetches the current url as JSON again and updates data; keeps the current data when the answer is not a henri page
router The Next.js router (withRouter)

pathFor(name) returns the route entry, { method, route, roles }; pathFor(name, '42') returns the route as a string with :id filled; pathFor(name, { id: '42' }) returns { method, route } with every parameter filled (the object stringifies to its route). An unknown name returns undefined and logs a warning. Parameters are replaced whole, so :id never touches :identifier.

fetch(target, body) takes a url or a pathFor() result (its method is used) and goes through the native fetch with Accept: application/json, a JSON body, the same-origin cookies and the X-CSRF-Token header when there is a token. It resolves with the parsed body (JSON when the server sends JSON, text otherwise, null for a 204). A non-2xx answer rejects with a RequestError: message, statusCode, error and data from the boom body, status, body and response from the answer.

const destroy = async (id) => {
await fetch(pathFor('destroy_tasks_path', { id })); // DELETE /tasks/:id
await hydrate();
};
app/views/components/nav.js
import Link from 'next/link';
import { useHenri } from '@usehenri/react';
export default function Nav() {
const { user, getRoute } = useHenri();
return (
<Link href={getRoute('index_tasks_path')}>
{user ? user.email : 'Tasks'}
</Link>
);
}

Any folder under app/views is importable by name (import Nav from 'components/nav', import styles from 'styles/tasks.module.scss'), thanks to jsconfig.json. Global stylesheets are imported from app/views/pages/_app.js (a Next.js rule), which is where the Tailwind stylesheet is imported; sass resolves imports from app/views/styles, app/views and node_modules.

@usehenri/react/forms exports Form, Input, Select, Radio, Editor, Button, FormError, the useForm() hook and FormContext. A form keeps its fields’ data, validates on change with validator.js rules, sanitizes on submit and posts through fetch().

app/views/pages/tasks/new.js
import withHenri from '@usehenri/react';
import { Button, Form, FormError, Input, Select } from '@usehenri/react/forms';
const NewTask = ({ pathFor, router }) => (
<Form
action={pathFor('create_tasks_path')}
onSuccess={() => router.push('/tasks')}
>
<FormError />
<Input
name="name"
placeholder="Name"
required
validation={{ isLength: { min: 1 } }}
errorMsg={{ isLength: 'Minimum 1 char' }}
sanitation={{ trim: true }}
/>
<Select
name="category"
placeholder="Category"
choices={['low', 'medium', 'high', 'urgent']}
/>
<Button>Create Task</Button>
</Form>
);
export default withHenri(NewTask);
  • Form props: action (a route string or a pathFor() result, POST by default), method, data (initial values, followed when the prop changes), onSuccess(data, result), onError(message, error), onFail (message when the server sends none), error (a message to display), handleSubmit(action, data, clear) (replaces the request; not with action), name, className, debug. After a successful submission the form calls hydrate(), then onSuccess, then clears itself; it stays disabled until the request settles.
  • Field props: name (nested names like address.street work), validation ({ isEmail: true, isLength: { min: 3 } }, any validator.js function), sanitation ({ trim: true, escape: true }, applied in order), errorMsg ({ rule: message }), required, disabled, placeholder, className, plus anything the underlying element accepts. Select takes choices (strings, or objects read through displayProp, name by default, valued by externalId, _id, id or value) and renders placeholder as a real first option. Radio takes group (the data key) and name (the value it sets). Editor is a Quill rich text field loaded in the browser only, controlled by the form data; import react-quill-new/dist/quill.snow.css from the page that uses it.
  • A 422 answer whose data.errors maps field names to messages (what the scaffolded controllers send) shows each message under its field; any other error goes to FormError.
  • useForm() gives a custom field data, errors, error, disabled, modified, handleChange(event, validation), handleSubmit(), clear() and addSanitizer(name, rules).

Export a next function (or a plain object) from config/next.js to change the Next.js configuration under either bundler:

config/next.js
module.exports = {
next: (config) => ({ ...config, images: { unoptimized: true } }),
};

If you need webpack-specific hooks, export a webpack function from config/webpack.js. Its presence switches the engine from Turbopack to webpack. The function must be synchronous and return the configuration it received (a missing module.rules or resolve fails the build with an explanation):

config/webpack.js
module.exports = {
webpack: (config, { dev }, webpack) => {
config.plugins.push(
new webpack.ProvidePlugin({ $: 'jquery', jQuery: 'jquery' })
);
return config;
},
};

Both files are read once, at boot and by next build. Saving them triggers a reload that changes nothing; the terminal asks for a restart.

henri server --production (or NODE_ENV=production) builds the pages once, when app/views/.next/BUILD_ID (or the distDir of your config/next.js) is missing, and serves the optimized build. --force-build rebuilds even if a build exists. henri build runs next build on its own without booting henri, so it needs neither a database nor the stores: use it in a Docker build stage (the repository’s docker/Dockerfile does).

assets.prefix is what goes in front of every url the production build wrote — the entry module and its chunks, the stylesheets, and the fonts and images those stylesheets name. It is Vite’s base and Next’s assetPrefix, said once, for whichever renderer the application uses.

{ "assets": { "prefix": "https://cdn.example.com" } }

An absolute http(s) url moves the assets to another host; a path (/assets) keeps them on this one. No credentials, no query and no fragment: it is a prefix, not a link, and the boot refuses anything else.

henri names the origin in the Content Security Policy itself. This is the part that would otherwise cost an afternoon: default-src 'self' and script-src 'self' refuse a script from another origin, so an asset prefix that did not reach the policy would give you an application that boots, answers 200 and paints nothing, with the reason only in the browser console. The origin is added to script-src, style-src, font-src, img-src, connect-src, worker-src and media-src — everything a compiled application loads — and to nothing else. default-src is deliberately left alone: widening it would let the asset host be framed and embedded as well, and the one thing that falls back to it is a <link rel="prefetch">, where a refusal costs a warm cache and never a page. Nothing goes in config.helmet.

The nonce still lands on every tag, prefixed or not.

Three things worth knowing:

  • The application still serves the assets. express.static (Inertia) and Next’s own handler are untouched, at the same paths as before, which is exactly what an origin-pull CDN pulls from. Point the CDN at the application and it works with nothing else configured.
  • It applies to the production build only. In development the Vite dev server and Next serve from this origin, because there is no build sitting on the other host yet. The policy names the origin in every environment all the same — it costs a request nothing, and it means a production configuration is never one directive away from a blank page.
  • Set it before you build. The prefix is compiled into the bundle: it is what the urls inside a chunk are written against (a lazy import, a font a stylesheet names), not only what the document’s tags say. henri build reads the production configuration, so a deploy that builds and boots from the same files is already consistent; changing the prefix means building again.

henri audit reports an http:// prefix in a production configuration (assets.plaintext-prefix), because over https the browser refuses every script of every page.

This is not uploads.urls.cdn, which is the other key with a host in it. That one puts a cache in front of henri’s own /_uploads route: the cache forwards the path and the query to this application, which checks a signature and streams a file somebody uploaded. assets.prefix names a host that serves files the build wrote, which the application never looks at again. One forwards to henri, the other replaces it — which is why they do not share a word.

The template renderer serves the .hbs, .html and .htm files under app/views/pages and registers everything under app/views/partials as partials, named by their path without extension ({{> menu/left}} for partials/menu/left.hbs). It has no build step and no client-side JavaScript of its own.

{ "renderer": "template" }
<html>
<head>
<title>Hello!</title>
</head>
<body>
{{> somePartials }}
<li>Some data: {{hello}}</li>
<p>{{@user.email}} may go to {{@paths.index_artwork_path.route}}</p>
</body>
</html>

The object passed as data to res.render() is the template context. The other view options are data variables: {{@user.email}}, {{@paths.index_artwork_path.route}}, {{@query.page}}, {{@localUrl}}, {{@csrf}} (put it in a hidden _csrf field of your forms), {{@errors}}, {{@graphql.endpoint}}, {{@nonce}}.

There is one helper, {{nonce}}: the Content Security Policy nonce of this response, for the inline scripts a template writes itself. It is the same value as {{@nonce}}, and it is empty unless "csp": { "nonce": true } is on.

<script nonce='{{nonce}}'>
window.APP = { started: Date.now() };
</script>

A route resolves to exactly one file: /artwork is pages/artwork.{hbs,html,htm}, then pages/artwork/index.{hbs,html,htm}, and / is pages/index.*. A route without a page is a 404; a template that fails while rendering is a 500 with the stack logged (and shown in development). Templates are compiled once, recompiled when the file changes and dropped on reload; a partial that does not compile is reported and skipped.

This engine exists in every application whatever the renderer: res.hbs() renders a Handlebars page from a React or Inertia application, and the mail views under app/views/mailers are rendered with it, with the same partials. A renderer that wants to render mail itself implements renderMail({ view, layout, data, meta }) on its engine and answers { html, text } or a string; neither the React nor the Inertia engine does today.

The vue renderer drives Nuxt the same way the React renderer drives Next.js: pages under app/views, data injected by controllers.

{ "renderer": "vue", "experimental": { "vue": true } }

Every controller that renders a view also answers with JSON. Request the same url with an Accept: application/json header and you get { csrf, data, errors, graphql, localUrl, paths, query, user }, the object the page was rendered with. This is what hydrate() and client-side navigation use under the hood.