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-onlypackage — the RSC layer resolves itsreact-servercondition, 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
errorpage 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.