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
checkOrigin: true, // CSRF origin check on server-action POSTs
allowedOrigins: [], // extra origins allowed to post actions, e.g. ['https://admin.example.com']
csp: false, // strict per-request-nonce Content-Security-Policy
cspDirectives: {}, // widen the built-in CSP, e.g. { 'img-src': "'self' https://cdn.example.com" }
bodySizeLimit: '1mb', // request body cap: '512kb' | 4_000_000 | false to disable
rspack(config, { isServer, isDev }) {
return config; // escape hatch: mutate the generated Rspack config
},
});
The framework settings — trustProxy, checkOrigin, allowedOrigins, csp, cspDirectives,
bodySizeLimit — are resolved at build time and compiled into the server bundle. There is no
parallel env-var interface for them, so changing one 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.
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.
CSRF
Server-action POSTs are origin-checked automatically. A cross-origin Origin compared against your own
host is rejected with 403, as is anything the browser labels Sec-Fetch-Site: cross-site or same-site.
A browser-asserted same-origin is accepted directly, which keeps the check from misfiring behind a
proxy that rewrites Host.
It applies to client-initiated calls and no-JS form posts alike. Turn it off with checkOrigin: false
behind a gateway that enforces it, or list trusted cross-origins in allowedOrigins — full origins or
bare hosts; a malformed entry fails the build.
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.
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.
Request-body limit
Bodies are capped (bodySizeLimit, default 1 MiB) before being buffered into memory; oversized ones
are rejected with 413. This covers every route, not just actions — endpoint routes and the
src/server.ts sub-app are equally exposed the moment they call .json() or .formData(). An over-cap
Content-Length is refused up front; chunked bodies are cut off mid-stream. Set false to disable.
Response headers and caching
Unconditionally, on every response:
X-Content-Type-Options: nosniff
Referrer-Policy: strict-origin-when-cross-origin
X-Frame-Options: SAMEORIGIN
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.
Every page response carries Vary: Accept, because one URL answers with either an HTML document or a
flight payload depending on it.
CSP (opt-in)
csp: true sends a strict per-request-nonce Content-Security-Policy with every HTML document; the
nonce is stamped on bootstrap scripts, the inlined flight payload and dynamically loaded chunks.
Beyond default-src 'self' it closes the gaps default-src does not cover — base-uri, object-src,
frame-ancestors, form-action — so it blocks framing and third-party assets until widened with
cspDirectives. The nonce is always re-appended to script-src, and '' drops a directive. While
enabled, the document for a static route is rendered per request.
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.