Core concepts
Routing
src/routes.ts — the one file rshono requires, and the only place routes are declared.
Routes are an explicit table, not a directory scan. One file lists every page and endpoint, matched in order.
import { defineRoutes } from '@rshono/core';
export const routes = defineRoutes({
routes: [
{ path: '/', component: () => import('./components/home') },
{ path: '/profile/:id', component: () => import('./components/profile') },
{
path: '/docs/:slug',
render: 'static',
component: () => import('./components/documentation'),
staticPaths: async () => [{ slug: 'getting-started' }, { slug: 'deployment' }],
},
{ type: 'endpoint', path: '/api/health', server: () => import('./health') },
],
notFound: { component: () => import('./components/404') },
error: { component: () => import('./components/500') },
});
routes.ts only ever runs on the server, so importing server-only modules from it — inside
staticPaths, say — is safe. A plain array is shorthand when there are no notFound / error pages:
export const routes = defineRoutes([{ path: '/', component: () => import('./components/home') }]);
Paths
Paths use Hono's syntax: :id, :id{[0-9]+} and * all work.
PageProps<'/users/:id/posts/:postId'> turns that literal into { id: string; postId: string }, and
defineRoutes checks each page's props against the path it is mounted at. Out of step, and the
component field errors with component props are not satisfied by PageProps<'/…'> — at the route
definition, not at runtime.
Page routes
type can be omitted; 'page' is the default.
| Field | Meaning |
|---|---|
path |
Hono-style pattern, e.g. /, /profile/:id, /files/*. |
component |
Dynamic import of the page module; its default export is the page. |
render |
'static' prerenders at build time; 'dynamic' (the default) renders per request. |
staticPaths |
For a parameterised static route: the param sets to prerender, one file each. |
Write component inline as () => import('…'). The framework detects that exact form and injects
Rspack's 'use server-entry' directive for you, which is what attaches each page's client JS and CSS to
its component — per-route code splitting with no asset manifest. Wire a component up some other way
(variable indirection, barrel re-exports, computed specifiers) and the build throws a descriptive error
telling you to write the directive at the top of the page module yourself.
Static rendering
render: 'static' builds a route once, at build time, in both the forms a page is asked for:
index.html for a hard load, and index.rsc — the flight payload — for a soft navigation. Both carry a
weak ETag, so a revalidation costs a 304 rather than the page.
A static route with params needs staticPaths. It runs at build time only, on the server, so it may
hit a database or the filesystem:
staticPaths: async () => (await db.docs.all()).map((d) => ({ slug: d.slug })),
defineRoutes checks the param sets against the route's own path, the same way it checks a page's props:
a set that does not carry every :param of the path is a type error where the route is declared, not a
build-time throw. Keys only — a staticPaths typed as returning Record<string, string> has none to
check, so it is accepted and the build reports the mismatch instead.
Rules worth knowing:
- Reading
ctxthrows. There is no request at build time. Useparamsandurl, or make the route dynamic. - Set
siteUrlif the page builds absolute URLs. Without it the origin ishttp://localhostand the build warns. - Wildcard, optional and regex params cannot be prerendered. A parameterised static route without
staticPathsfalls back to per-request rendering, and the build warns. So does a page that did not render cleanly. - Under a nonce-based CSP the document is rendered per request — a prerendered file cannot carry a per-request nonce. The flight payload is still served from the prerender, and a policy with no nonce in it keeps its prerendered documents too.
Cache-Control: public, max-age=300, with a weakETag. To promise more — a longermax-age, astale-while-revalidate— set it from middleware afterawait next().
Endpoint routes
An endpoint route is served by a raw Hono handler instead of a component — JSON APIs, webhooks,
redirects, feeds. method defaults to 'all', takes one method or a list of them, and there is no
'head': Hono dispatches a HEAD as a GET and strips the body off the response, so 'get' already
answers both.
It is also how you answer a method a page does not. A page route is registered for GET and POST — plus
the HEAD that rides the GET — and anything else is a 404 rather than a 405, deliberately: the Allow
header a 405 owes the client would mean tracking the methods registered per path, for a distinction no client
here acts on differently.
{ type: 'endpoint', path: '/api/health', method: 'get', server: () => import('./health') }
// Two methods, one handler. Without the list this would be `'all'` plus a hand-rolled method check,
// which also answers every method you did not mean to. `'all'` inside a list is refused.
{ type: 'endpoint', path: '/api/session', method: ['get', 'delete'], server: () => import('./session') }
// src/health.ts
import type { Handler } from 'hono';
export const handler: Handler = (c) => c.json({ ok: true });
The module only ever loads on the server, so reading secrets from it is safe.
notFound and error
Both are optional, and both are real server components with a page's contract minus a path of their own.
-
notFound— rendered with a 404 for unmatched paths and fornotFound()calls. -
error— rendered with a 500 when a request throws, and given an extraerrorprop: message-only in production, message plus stack in dev.error.stackisundefinedin every build anderror.messageis the generic'Internal Server Error', so render the stack behind a check on the stack itself — there is no mode flag to check:export default function ServerError({ error }: ErrorPageProps) { return ( <Layout> <p>{error.message}</p> {error.stack && <pre>{error.stack}</pre>} </Layout> ); }
See Configuration & security for what happens when a failure is
bad enough that the error page itself cannot be reached.