Core concepts
Pages
A page is a React server component that renders the whole document and receives the request — and the 'use server' actions it mutates through.
Every page module default-exports a server component — nothing else. It may be async and await
data directly.
import type { PageProps } from '@rshono/core';
import { db } from '../db';
export default async function Profile({ params, ctx }: PageProps<'/profile/:id'>) {
const user = await db.getUser(params.id);
const theme = ctx.cookies.get('theme') ?? 'light';
return <Layout theme={theme}>{user.name}</Layout>;
}
Pages render the entire document (<html>…</html>), usually through a shared layout component.
Interactive parts are 'use client' components the page imports — only those ship JavaScript.
There is no <Link>, <Image>, <Script> or <Head>: links are <a href>, images are <img>, forms
are <form action>. Same-origin anchors are soft-navigated automatically, except a destination whose last
path segment carries a dot (/llms.txt, /report.pdf) — that is a file, not a page, so the browser loads it
as a document. data-native opts any link out by hand; an endpoint with no extension in its path
(/download?id=3) still needs it.
Page props
Every page receives { url, params, ctx }. They are server-only and never serialized — React puts a
server component's output on the wire, not its props.
| Prop | What it is |
|---|---|
url |
The absolute browser-facing URL, proxy-header aware. A fresh instance per request. |
params |
The matched route params. PageProps<'/profile/:id'> types params.id as string. |
ctx |
The request context — req, cookies, env, middleware variables. Same object getRequestContext() returns. Reads only in a page: writes throw. |
Type ctx.var and ctx.env for your app by passing its Hono Env:
export default function Dashboard({ ctx }: PageProps<'/dashboard', AppEnv>) {
const session = ctx.cookies.get('session');
if (!session) redirect('/login');
return <Layout>Signed in as {session}</Layout>;
}
ctx is non-enumerable, which is the one place these props break a JavaScript expectation: a
<Child {...props} /> spread hands the child ctx: undefined — silently, since a spread copies
enumerables only, and the type still says ctx is there. It has to be that way; an enumerable ctx
would put ctx.hono.env, every binding and secret, into React's dev-only serialization of a server
component's props. Don't spread page props: nested server components are meant to ask for the context
themselves.
Nested server components and 'use server' actions get no props — they call getRequestContext() from
@rshono/core/server for the same object. There, ctx.params stands in for the params prop and
ctx.req for the parsed request:
import { getRequestContext } from '@rshono/core/server';
async function Breadcrumb() {
const ctx = getRequestContext(); // no props down here
return (
<nav>
{ctx.params.id} · {ctx.req.method}
</nav>
);
}
A page can read ctx, not write to it
ctx.cookies.set(), ctx.cookies.delete() and ctx.setHeader() throw inside a page. A page
streams, so its response head is committed before the component runs — a cookie set here would arrive
on a full page load and silently vanish on a soft navigation. Write it from a
server action or from
middleware, both of which run first.
Hono's response builders are the same story, and say so: ctx.redirect(), ctx.notFound(), ctx.json()
and friends throw with a pointer to the API that works — redirect() and notFound() from
@rshono/core/server, or an endpoint route for a non-HTML response.
ctx.hono is the full Hono Context for the long tail, and going through it bypasses the error rather
than fixing it.
ctx cannot cross into the client
ctx wraps the live request and response, so it is non-enumerable and never reaches the browser.
- Passing it explicitly (
<Counter ctx={ctx} />) fails the render with React's "Only plain objects … can be passed to Client Components". - Spreading page props (
<Counter {...props} />) drops it silently — a spread copies enumerables only. That spread still fails, onurl, which is enumerable and just as unserializable. Into a server child the same spread fails at nothing:ctxis simplyundefinedthere. CallgetRequestContext()in the child.
Read what you need on the server and pass plain values down: url.href, not url.
On a prerendered page reading ctx throws — there is no request.
Client components
A 'use client' module is the interactive part, and useNavigation() is the whole client-side routing
API:
'use client';
import { useNavigation } from '@rshono/core/client';
export function NextPage() {
const { url, router } = useNavigation();
const page = Number(url.searchParams.get('page') ?? '1');
return (
<button disabled={router.pending} onClick={() => router.push(`${url.pathname}?page=${page + 1}`)}>
Next
</button>
);
}
url and params are the same names and types a page gets as props, so moving a read across the
server/client line is a copy-paste — apart from url.hash, which only the browser knows. A server never
receives a fragment, so useNavigation().url.hash is read from the address bar after hydration and
follows in-page links and Back/Forward. router holds push, replace, back, forward, refresh and
pending; every one is a soft navigation, so client state outside the changed subtree survives.
<AsyncBoundary> pairs a Suspense fallback with an error fallback, and <CatchBoundary> is the error
half alone. Both are 'use client' modules a server component can render directly. A redirect() is
never absorbed by either — it is navigation, not failure.
Server actions
A 'use server' module exports functions a client component can call directly, with typed arguments and
result:
'use server';
export async function createUser(data: { name: string; email: string }) {
// runs on the server, always
}
There is no route handler in between, and no client bundle carries the body — only a reference to it.
Usage covers wiring them into components with useActionState, useTransition and
useOptimistic; what follows is what an action is on this side of the wire.
Progressive enhancement
A <form action={createUser}> posts before hydration and with JavaScript disabled. The client
runtime upgrades it to a fetch once loaded; until then the browser's own form post does the job.
Every action response carries a fresh page payload, so server-rendered UI updates after a mutation with no refetch and no cache invalidation call.
Cookies and headers
An action runs before the page it re-renders, which is what makes it the place to write to the response — the rule above is the other side of the same coin:
'use server';
import { getRequestContext, redirect } from '@rshono/core/server';
export async function login(form: FormData) {
const ctx = getRequestContext();
ctx.cookies.set('session', await createSession(form), { httpOnly: true, sameSite: 'Lax', path: '/' });
redirect('/dashboard');
}
export async function logout() {
const ctx = getRequestContext();
ctx.cookies.delete('session', { path: '/' });
ctx.setHeader('clear-site-data', '"cache", "storage"');
redirect('/');
}
Both survive the redirect() — the signal is thrown after the cookie is already on the response.
For headers that belong to a route rather than to a mutation, use middleware.
Every action is a public endpoint
That is the RSC model, not an rshono choice: the client is handed an id for each action and can call it with whatever arguments it likes. A CSRF check proves a request came from your own site; it says nothing about who sent it. Authenticate, authorize and validate arguments inside the action, exactly as in a route handler.
An action's arguments are decoded from the request body, which means the body is buffered before your code
sees any of it. Register bodyLimit() so there is a size past
which that never happens — rshono build warns when nothing under src/ registers one.
Errors
Thrown action errors are logged server-side and redacted in the production payload — React sends no message and no digest. Return values, not throws, for anything the user should see:
'use server';
export async function createUser(data: FormData) {
const email = String(data.get('email') ?? '');
if (!email.includes('@')) return { ok: false, error: 'That email looks wrong.' };
return { ok: true };
}
Errors that do escape reach onServerError like everything else the
framework catches.
Secrets
Actions compile to server references and run only on the server, so they read the real process.env.
See Environment & secrets.
The 'use server-entry' directive
A technical note, not a step: none of the pages above write this directive, and neither does an app. It shows up in Rspack's output and in one error message, which is the only reason it is documented here.
Compiled pages carry Rspack's 'use server-entry', which hangs the page's own client JS and CSS off its
default export. The framework reads them back as the document's bootstrap scripts and stylesheet links —
per-page code splitting with no asset manifest. The build finds the pages to mark by reading routes.ts for
inline component: () => import('…') thunks and prepending the directive to exactly the modules they name.
It re-scans on every rebuild, so a route added while the dev server is running needs no restart.
Write the thunk inline and there is nothing to do. A component reached some other way — through a variable,
a barrel re-export, a computed specifier — is invisible to that scan, and a module that already opens with a
directive of its own is left untouched; in either case put 'use server-entry' on the page module's first
line yourself. A page that ends up without it renders into a descriptive error naming the route and both
ways of fixing it.