Skip to content
rshono
Documentation menu

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 — see Pages for when you have to write it 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 })),

Rules worth knowing:

  • Reading ctx throws. There is no request at build time. Use params and url, or make the route dynamic.
  • url.searchParams is always empty. One file answers every request whatever its query. Read the query with useNavigation().url on the client.
  • Set siteUrl if the page builds absolute URLs. Without it the origin is http://localhost and the build warns.
  • Wildcard, optional and regex params cannot be prerendered. A parameterised static route without staticPaths falls back to per-request rendering, and the build warns. So does a page that did not render cleanly.
  • With csp: true the document is rendered per request — a prerendered file cannot carry a per-request nonce. The flight payload is still served from the prerender.

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'.

{ type: 'endpoint', path: '/api/health', method: 'get', server: () => import('./health') }
// 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 for notFound() calls.
  • error — rendered with a 500 when a request throws, and given an extra error prop: message-only in production, message plus stack in dev.

See Configuration & security for what happens when a failure is bad enough that the error page itself cannot be reached.