Skip to content
rshono
Documentation menu

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, on url, which is enumerable and just as unserializable. Into a server child the same spread fails at nothing: ctx is simply undefined there. Call getRequestContext() 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.