Core concepts
Pages
A page is a React server component that renders the whole document and receives the request.
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; data-native opts one out.
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 — cookies, headers, env, middleware variables. Same object getRequestContext() returns. |
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>;
}
Nested server components and 'use server' actions get no props — they call getRequestContext() from
@rshono/core/server for the same object.
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.
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. router holds push, replace, refresh and pending; all three
are soft navigations, so client state outside the changed subtree survives. History traversal is
history.back() / history.forward().
<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.
The 'use server-entry' directive
Each page carries Rspack's 'use server-entry' directive, which attaches the page's client JS and CSS
to the component. That is what gives per-page code splitting with no asset manifest.
The framework injects it for every component written with the inline component: () => import('…') form
in routes.ts, including routes added while the dev server is running. If a component is wired up some
other way — variable indirection, barrel re-exports, computed specifiers — write 'use server-entry' as
the first line of the page module yourself. The framework throws a descriptive error when neither
happened.