Views
Pick a renderer in your configuration. All of them are server-side rendered; the React renderer also pushes updates to the browser while you develop.
{ "renderer": "react" }Next.js (16, pages router, Turbopack) renders the pages in app/views/pages and injects the data sent by your controllers. If none of your routes match a request but a page does, the page is rendered directly.
Install the peer dependencies in your project (henri new already does):
pnpm add @usehenri/react next react react-dom sassTwo small files live next to your pages. henri new ships them and the engine creates them on first boot if they are missing:
module.exports = require('@usehenri/react/engine/conf');{ "compilerOptions": { "baseUrl": "." } }import Link from 'next/link';import withHenri from '@usehenri/react';
const Log = ({ data }) => ( <div> <pre>{JSON.stringify(data, null, 2)}</pre> <Link href="/home">Home</Link> </div>);
export default withHenri(Log);withHenri gives your page these props:
| Prop | Description |
|---|---|
data |
What the controller passed to res.render() (or the GraphQL result) |
user |
The logged-in user, or null |
paths |
The routes this user may call, keyed like index_tasks_path |
pathFor |
pathFor('show_tasks_path', id) builds a URL from a route name |
getRoute |
Same, returns the plain string |
fetch |
fetch({ route, method }, body) calls a controller and resolves its JSON |
hydrate |
Refetches the current page’s data and updates data |
Nested components get the same helpers from the useHenri() hook (or the HenriContext it reads):
import { useHenri } from '@usehenri/react';
export default function Nav() { const { user, pathFor } = useHenri(); return ( <a href={pathFor('index_tasks_path')}>{user ? user.email : 'Tasks'}</a> );}A set of form components (Form, Input, Select, Radio, Editor, Button, FormError) with a useForm() hook ships in @usehenri/react/forms. Editor wraps Quill; import react-quill-new/dist/quill.snow.css from the page that uses it.
assets, components, helpers and styles resolve to the matching folders under app/views, so import Nav from 'components/nav' works from any page. Global stylesheets are imported from app/views/pages/_app.js (a Next.js rule); component styles use CSS modules, import styles from 'styles/tasks.module.scss'.
Extending Next.js
Section titled “Extending Next.js”Export a next function (or a plain object) from config/next.js to change the Next.js configuration under either bundler:
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:
module.exports = { webpack: async (config, { dev }, webpack) => { config.plugins.push( new webpack.ProvidePlugin({ $: 'jquery', jQuery: 'jquery', }) ); return config; },};Production
Section titled “Production”henri server --production (or NODE_ENV=production) builds the pages once and serves the optimized build. henri build runs the build on its own, for example in a Docker image. --force-build rebuilds even if a build exists.
Handlebars
Section titled “Handlebars”The template renderer serves the .html and .hbs files under app/views/pages and registers everything under app/views/partials as partials. 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> </body></html>The object passed as data to res.render() is the template context.
The vue renderer drives Nuxt the same way the React renderer drives Next.js: pages under app/views, data injected by controllers.
{ "renderer": "vue" }Fetching data again
Section titled “Fetching data again”Every controller that renders a view also answers with JSON. Request the same URL with an Accept: application/json header and you get the data, user, paths and graphql keys the page was rendered with. This is what hydrate() and fetch() use under the hood.
