Skip to content
rshono
Documentation menu

Advanced

Configuration & security

rshono.config.ts — every field, the secret boundary, and what is hardened by default.

An optional rshono.config.ts (.js / .mjs also work) at the project root tunes the framework. Every field is optional; delete the file to accept all defaults.

import { defineConfig } from '@rshono/core';

export default defineConfig({
  deploy: 'node', // hosting platform to build for (--deploy or RSHONO_DEPLOY override)
  siteUrl: 'https://example.com', // public origin, baked into prerendered pages' absolute URLs
  trustProxy: false, // honour X-Forwarded-Host/-Proto — only behind a proxy you control
  rspack(config, { isServer, isDev }) {
    return config; // escape hatch: mutate the generated Rspack config
  },
});

That is the whole file, and deliberately so: it holds only what the build decides. Everything that is a per-request concern — CSRF, CSP, the request-body cap — is Hono middleware in src/server.ts, because Hono already ships all of it and a config field would only be a worse way to spell the same call. See security middleware below.

trustProxy is resolved at build time and compiled into the server bundle, so changing it means a rebuild. Two deployment-conventional exceptions stay env-overridable: --port / PORT and HOST, each winning over the file, which wins over the default. Point a build at a different file with --config <path>.

siteUrl

The public origin the site is served from. Used only when prerendering render: 'static' routes, where there is no request to read a Host from. The origin is what's used; a path is rejected rather than silently dropped, because a base path is not supported.

The rspack hook

Mutate the generated Rspack config just before it is compiled:

rspack(config, { isServer, isDev }) {
  config.module!.rules!.push({ test: /\.md$/i, type: 'asset/source' });
}

Called once per compiler — isServer tells the target: node bundle from the target: web one, isDev tells rshono dev from rshono build. Mutate in place and return nothing, or return a replacement. This is how Tailwind is wired up.

Environment and secrets

The client/server boundary is the RSC directives — 'use client' and 'use server' — not filenames, and process.env follows it.

In client code process.env is replaced at build time with a literal containing only NODE_ENV and PUBLIC_-prefixed variables. A stray process.env.DATABASE_URL compiles to undefined. That is a hard guarantee rather than tree-shaking, and it covers node_modules too.

'use client' modules are also rendered on the server, and there they see the same PUBLIC_-only view, so SSR output agrees with hydration and a secret cannot leak into the HTML stream. One boundary on that: the SSR-side shadowing is scoped to your own src/, so a third-party client component reading process.env during SSR sees the real environment.

Server components and 'use server' actions read the real process.env. They run only on the server, so a secret read there never reaches the browser. Read secrets in server code and pass derived data down.

.env.local and .env are loaded automatically, and the real environment wins over both. Commit .env with safe defaults; keep .env.local gitignored.

Both are read once, when the process starts, and the PUBLIC_ view of them is compiled into the bundle by DefinePlugin — so rshono dev does not pick up an edit to either. A rebuild is not enough and does not look like a failure: the page rebuilds, serves, and shows the old value. Restart the dev server. It watches both files and says so when one changes, and the same holds for rshono.config.ts, which is read once too.

getRequestContext().env is that same environment, plus the platform's own runtime bindings where it has them — deploy: 'cloudflare', where a KV namespace or a Worker secret arrives per request and wins over a process variable of the same name. On every other target it is process.env alone: Hono's c.env there is the adapter's private state rather than your bindings, so the framework does not merge it. ctx.hono.env still reaches it if you need it.

Two things worth remembering:

  • Anything a server component renders is public. Whatever is in the tree ships in the flight payload. The boundary protects process.env, not your JSX.
  • Keeping a server-only module out of the client bundle is the module graph's job. For a hard failure if that slips, add React's server-only package — the RSC layer resolves its react-server condition, so importing it from client code throws.

Security middleware

The framework runs no CSRF check, body cap or CSP of its own. All three are Hono middleware you register in src/server.ts, which is mounted ahead of the page routes — so anything registered there wraps page renders and server actions, not just your own handlers.

create-rshono scaffolds the first two into every new app. An app written by hand down to src/routes.ts has neither until it adds them.

// src/server.ts
import { publicUrl } from '@rshono/core/server';
import { Hono } from 'hono';
import { bodyLimit } from 'hono/body-limit';
import { csrf } from 'hono/csrf';

const server = new Hono();

server.use(bodyLimit({ maxSize: 1024 * 1024 }));
server.use(csrf({ origin: (origin, c) => origin === publicUrl(c).origin }));

export default server;

bodyLimit caps a request body before it is buffered into memory and answers 413. Registered here it covers every route, not just actions — endpoint routes and your own handlers are equally exposed the moment they call .json() or .formData(). An over-cap Content-Length is refused up front; a chunked body is cut off mid-stream.

csrf rejects a cross-origin POST with 403 before it can reach a server action. It layers Sec-Fetch-Site over Origin, and only inspects the content types a browser can send cross-origin without a preflight — which is exactly the shape a server action arrives in: text/plain for a client-initiated call, multipart/form-data for a no-JS form post. A JSON API route is unaffected.

Pass publicUrl(c) rather than relying on the default same-origin comparison, which reads c.req.url — the address the server was reached on. Behind a proxy that terminates TLS or rewrites Host that is the internal address, and a legitimate post would then be riding on Sec-Fetch-Site alone. publicUrl honours trustProxy, so it stays c.req.url where no proxy is declared. To allow another origin:

server.use(
  csrf({
    origin: (origin, c) => origin === publicUrl(c).origin || origin === 'https://admin.example.com',
  }),
);

The check proves a request came from your own site. It says nothing about who sent it — every 'use server' export is a public endpoint.

Everything else Hono ships works the same way — cors, basicAuth, jwt, ipRestriction, timeout, requestId, and secureHeaders below. A middleware that rejects by throwing an HTTPException keeps its own status, rather than being rendered as the 500 page. See Hono & middleware for worked examples and Hono's docs for the full set.

Proxy headers

X-Forwarded-Host and X-Forwarded-Proto are client-supplied and not trusted by default. Honouring them blindly lets anyone who can reach the server dictate the origin of every absolute URL the app builds, poisoning canonical tags, emails, redirects and any shared cache in front. Set trustProxy: true only when a proxy you control sets them. rshono dev forces it on for its own localhost-bound proxy.

It governs the browser-facing URL the framework builds — a page's url prop and getRequestContext().url. Middleware is handed Hono's c and reads c.req.url on its own, so where a middleware needs the public origin, give it publicUrl(c) from @rshono/core/server.

Leave it off on vercel, where the request already arrives with the scheme and host the browser used — that platform's edge sets them, and the function cannot be reached around it. To be precise about that target: the scheme is taken from X-Forwarded-Proto whether or not trustProxy is set, because there it is not client-supplied. The Host header is not part of the exception. The setting is for a proxy you put in front yourself, which the framework cannot vouch for.

Response headers and caching

A floor, on every response — including /_static assets, which your middleware never sees because they are served before it:

X-Content-Type-Options: nosniff
Referrer-Policy: strict-origin-when-cross-origin
X-Frame-Options: SAMEORIGIN

Each is set only if nothing already did, so secureHeaders() in src/server.ts wins wherever both apply. That is the way to add the rest — HSTS, COOP, CORP, Permissions-Policy.

A dynamic page is answered Cache-Control: private, no-cache — a page is request-specific by default, and with no directives a shared cache is free to store one user's page and serve it to the next. Set your own value from middleware and it is left alone. Prerendered pages keep public, max-age=300 and a weak ETag.

Those two values are not config fields: a cache policy is a per-response header, and rshono.config.ts is compiled into the bundle, so a value you cannot change without a rebuild would be the wrong shape. Middleware is the interface — and for a prerendered page it has to run after await next():

// src/server.ts — a documentation site's prerendered tree, cached for a day
server.use('/docs/*', async (c, next) => {
  await next();
  c.res.headers.set('cache-control', 'public, max-age=86400, stale-while-revalidate=604800');
});

After, because the prerendered response is built with cache-control in the header bag, which replaces anything c.header(...) prepared before the handler ran. A dynamic page is the easier case: its default is applied only if nothing else set one, so c.header(...) before await next() works there. Editing c.res.headers after works for both, leaves the ETag alone, and so keeps revalidation at a 304.

Every page response carries Vary: RSC, because one URL answers with either an HTML document or a flight payload depending on that request header — the client runtime sends RSC: 1 to ask for a payload. A header of its own rather than Accept: a browser's Accept string differs by vendor and version, so a CDN keyed on it would store a copy of a prerendered page per variant, and some shared caches refuse to store such a response at all.

CSP

A Content-Security-Policy comes from secureHeaders() in src/server.ts. With no arguments it sets the rest of the usual set — HSTS, COOP, CORP, nosniff, X-Frame-Options — and no CSP at all:

// src/server.ts
import { secureHeaders } from 'hono/secure-headers';

server.use(secureHeaders());

Put Hono's NONCE placeholder in scriptSrc and the policy becomes per-request: Hono mints a nonce and rshono stamps that value onto the bootstrap scripts, the inlined flight payload and dynamically loaded chunks.

// src/server.ts
import { NONCE, secureHeaders } from 'hono/secure-headers';

server.use(
  secureHeaders({
    contentSecurityPolicy: {
      defaultSrc: ["'self'"],
      scriptSrc: ["'self'", NONCE],
      styleSrc: ["'self'", "'unsafe-inline'"], // React writes inline styles
      imgSrc: ["'self'", 'data:'],
      connectSrc: ["'self'"],
      // Not covered by default-src, and each closes an injection route of its own.
      baseUri: ["'self'"],
      objectSrc: ["'none'"],
      frameAncestors: ["'none'"],
      formAction: ["'self'"],
    },
  }),
);

Two things the framework does for you. React Refresh compiles updates with eval, so script-src is widened with 'unsafe-eval' under rshono dev and never in a build — one policy serves both. And while a nonce is in play the document for a static route is rendered per request, since a prerendered file is fixed bytes and cannot carry a fresh one. That is decided per request: a policy with no NONCE in it keeps its prerendered documents. The flight payload never carries a nonce and is served from the build either way.

Every directive, Permissions-Policy and the reporting headers are in Hono — Secure Headers.

Errors and redaction

Every error the framework catches goes through onServerError and to stderr. Thrown server-action errors are redacted in the production payload — React sends no message or digest — so return values, not throws, for anything the user should see. The error page's error prop is message-only in production, message plus stack in dev.

No blank screens

Three fallbacks behind the error page, so a failure is always readable:

  • An uncaught client-side render error makes React tear down its root — which here is the whole document. The runtime paints a fatal overlay instead: full stack in dev, a generic notice plus a reload button in production.
  • If SSR fails before the shell is sent, the error page cannot be reached either, so the framework answers with its own visible 500 document. It attaches no client runtime deliberately: the flight payload came from the same failed render, and hydrating it would blank the message.
  • A client bootstrap failure — a truncated or malformed initial payload — is reported and surfaced rather than becoming a silent unhandled rejection.