Core concepts
Hono & middleware
src/server.ts is a Hono sub-app — middleware, endpoints, end-to-end types, error reporting.
There is a whole Hono app under the pages, and it is yours. src/server.ts may default-export a sub-app:
any method, streaming, cookies, middleware.
import { Hono } from 'hono';
import { trimTrailingSlash } from 'hono/trailing-slash';
export type AppEnv = { Variables: { requestId: string } };
const server = new Hono<AppEnv>();
server.use(trimTrailingSlash({ alwaysRedirect: true }));
server.use('*', async (c, next) => {
c.set('requestId', crypto.randomUUID());
await next();
});
server.get('/api/health', (c) => c.json({ status: 'ok', requestId: c.var.requestId }));
export default server;
export type AppType = typeof server;
The sub-app is mounted at / ahead of the page routes, so its middleware — auth, logging,
trailing-slash — wraps page requests too.
The flip side: a terminal handler at the same path as a page route shadows the page. Middleware
that calls next() is fine; a handler that returns a response is not.
Security middleware
Because the sub-app wraps page requests, this is also where the app's security controls go. The
framework ships none of its own — Hono's are better tested than anything rshono would write, and a
config field would only be a worse way to spell the same call. create-rshono puts the first two in
every new app.
Request-body limit
Caps a body before it is buffered into memory and answers 413. Registered here it covers
everything downstream — pages, server actions and your own handlers — since any of them is exposed the
moment it calls .json() or .formData():
import { bodyLimit } from 'hono/body-limit';
server.use(bodyLimit({ maxSize: 1024 * 1024 })); // 1 MiB
An over-cap Content-Length is refused up front; a chunked body is cut off mid-stream. Raise it on
one path rather than globally where you accept uploads:
server.use('/api/upload', bodyLimit({ maxSize: 50 * 1024 * 1024 }));
CSRF
Rejects a cross-origin POST with 403 before it can reach a server action:
import { publicUrl } from '@rshono/core/server';
import { csrf } from 'hono/csrf';
server.use(csrf({ origin: (origin, c) => origin === publicUrl(c).origin }));
Use publicUrl(c) rather than Hono's default same-origin comparison, which reads c.req.url — the
address this server was reached on, which is the internal one behind any proxy, rshono dev
included. It honours trustProxy, so it stays c.req.url where
no proxy is declared. To allow more origins:
server.use(
csrf({
origin: (origin, c) => origin === publicUrl(c).origin || origin === 'https://admin.example.com',
}),
);
It only inspects the content types a browser can send cross-origin without a preflight — which is exactly the shape a server action arrives in, so a JSON API route is unaffected.
Security headers and CSP
secureHeaders() with no arguments sets a sensible set — HSTS, COOP, CORP, nosniff,
X-Frame-Options and more:
import { secureHeaders } from 'hono/secure-headers';
server.use(secureHeaders());
Add Hono's NONCE placeholder to scriptSrc and the CSP becomes per-request: Hono mints the nonce,
and rshono stamps that value onto the bootstrap scripts and the inlined flight payload.
import { NONCE, secureHeaders } from 'hono/secure-headers';
server.use(
secureHeaders({
contentSecurityPolicy: {
defaultSrc: ["'self'"],
scriptSrc: ["'self'", NONCE],
styleSrc: ["'self'", "'unsafe-inline'"], // React writes inline styles
},
}),
);
Nothing there mentions 'unsafe-eval': React Refresh needs it, so rshono widens script-src under
rshono dev and never in a build — one policy serves both. See CSP for the
full policy and what a nonce costs a static route.
Rejections keep their status
Middleware that refuses a request by throwing an HTTPException — csrf() with a 403, bodyLimit()
with a 413, your own guard — is handed straight back rather than rendered as the 500 page:
import { HTTPException } from 'hono/http-exception';
server.use('/admin/*', async (c, next) => {
if (!c.req.header('authorization')) throw new HTTPException(401, { message: 'Unauthorized' });
await next();
});
One gap: /_static assets are served before the sub-app is reached, so middleware never sees them. The
framework sets nosniff, Referrer-Policy and X-Frame-Options on
every response to cover them, and stands aside
wherever your own secureHeaders() set the same header.
Other middleware worth knowing
Everything Hono ships works here, because this really is a Hono app. The ones that come up most:
| Middleware | For |
|---|---|
cors |
Cross-origin access to your { type: 'endpoint' } API routes |
basicAuth |
Password-gating a staging deploy or an admin path |
bearerAuth |
Static-token auth on an API route |
jwt |
Verifying a JWT and putting its payload on c.var.jwtPayload |
requestId |
A trace id per request, readable from a page as ctx.var.requestId |
logger |
Method, path, status and duration per request |
timeout |
Refusing a request that outruns a deadline |
ipRestriction |
Allow/deny lists in front of an internal route |
trailingSlash |
Keeping /about and /about/ from being two pages |
Three exceptions, where the framework already owns the job or the two do not mix:
etagdigests the whole response before sending it, so it would buffer a streaming page render end to end. Prerendered pages already carry a weakETagand answer304.serveStaticis unnecessary —/_staticandpublic/are mounted for you.jsxRendereris for Hono's own JSX; here React owns rendering.
The full list is in Hono's docs, alongside its helpers —
cookie, proxy, streaming, testing and the rest.
Typing the context
The Env given to the Hono app is the same one that types ctx on a page. Pass it to PageProps and
ctx.var is typed key by key instead of being an open record:
import type { PageProps } from '@rshono/core';
import type { AppEnv } from '../server';
export default function Home({ ctx }: PageProps<'/', AppEnv>) {
return <p>Request {ctx.var.requestId}</p>; // typed
}
Response headers and cookies
Middleware is where a page's response headers belong. A page renders too late to set one — its response
head is committed before the component runs, so ctx.setHeader() throws
there. Middleware runs first, and gets Hono's c directly:
import { setCookie } from 'hono/cookie';
server.use('/blog/*', async (c, next) => {
await next();
c.header('cache-control', 'public, max-age=600, s-maxage=3600');
});
server.use('*', async (c, next) => {
if (!c.req.header('cookie')?.includes('visitor=')) setCookie(c, 'visitor', crypto.randomUUID(), { path: '/' });
await next();
});
Matching on a path pattern is deliberate: one middleware covers a group of routes, so caching policy lives in one place rather than being repeated on every route that shares it.
getRequestContext() is not available in middleware or in { type: 'endpoint' } routes — the
request context is bound around the render and the actions it runs, not around the whole Hono stack.
Neither needs it: both are handed c, which is a superset.
For a header that depends on a mutation rather than on the route — a session cookie after login — use a server action instead.
End-to-end types for a client
export type AppType = typeof server gives typed paths, params and responses with hono/client,
checked against the handlers themselves:
import { hc } from 'hono/client';
import type { AppType } from './server';
const client = hc<AppType>('/');
const res = await client.api.health.$get();
Error reporting
One handler, registered at the top level of src/server.ts, catches every error the framework sees — a
thrown action, a failed render, SSR falling over, anything reaching the top-level handler:
import { onServerError } from '@rshono/core/server';
onServerError((error, { source, request, hono, waitUntil }) => {
waitUntil(
Sentry.captureException(error, {
tags: { source, requestId: hono.var.requestId },
extra: { url: request.url },
}),
);
});
source is 'action' | 'render' | 'ssr' | 'request'. Errors keep going to stderr either way, and a
handler that throws is caught rather than failing the request.
hono is the request's Hono context — hono.var for what your middleware put there, hono.env for
bindings. It is handed over because getRequestContext() is not reachable from this handler: a
source: 'request' error is reported from the top-level handler, outside the ambient context.
waitUntil holds the invocation open until the report has been sent. On Cloudflare Workers that is
executionCtx.waitUntil, without which a report started here is cut off the moment the response ends. On
the node and vercel targets there is nothing to hold open — the process outlives the response — so it
is a no-op and the report finishes on its own. On aws-lambda it is a no-op as well, because the streaming
handler exposes no execution context to ask; a slow report there is best-effort, so prefer a tracker that
batches over one that round-trips per error.