renderPage
renderPage is the SSR entry point exported from hono-preact/server. It takes a Hono context and a root Preact node, prerenders it to HTML, injects head tags collected by hoofd, and returns a full HTML response. The framework always emits the <!doctype html> prefix and post-processes hoofd-collected head tags into the document's </head>. When the rendered root already returns an <html> element (the default when Layout.tsx uses <Head>), renderPage skips the outer <html lang> wrap and uses your shell as-is.
Usage
import { renderPage } from 'hono-preact/server';
app.get('*', (c) => renderPage(c, <Layout context={c} />));
That single line replaces the boilerplate for dispatcher setup, prerendering, head tag injection, and HTML assembly that would otherwise live in every app's catch-all handler.
API
renderPage(
c: Context,
node: VNode,
options?: { defaultTitle?: string; appConfig?: AppConfig }
): Promise<Response>
| Param | Description |
|---|---|
c | The Hono Context from your route handler |
node | The root Preact element to render, typically your <Layout> |
options.defaultTitle | Fallback <title> when no page sets one via hoofd. Defaults to ''. Express defaults via <Head defaultTitle="..."> in your Layout for the common case; the option is here for custom server entries that call renderPage directly. |
options.appConfig | The default-exported result of defineApp(). Surfaces app-level config to the render: middleware composition via use, plus opt-in features like speculation. The framework's generated server entry threads this for you; custom server entries must pass it explicitly to enable those features. |
Head tags
Head tags (<title>, <meta>, <link>) are collected during prerender via hoofd. Pages set them with hoofd's useTitle, useMeta, and useLink hooks:
import { useTitle } from 'hoofd/preact';
export default function MoviesPage() {
useTitle('Movies');
return <main>…</main>;
}
renderPage injects the collected tags into the <head> of the rendered HTML before returning the response.
Redirect outcomes
If a middleware throws redirect('/path') during SSR, renderPage catches the outcome and turns it into an HTTP redirect via c.redirect(), so you don't need to handle it in the route handler. Deny outcomes thrown during SSR map to an HTTP response at the deny's status; render outcomes substitute an alternative component into the prerender tree.
Example: custom server entry (advanced)
Most apps use the framework's generated server entry and never write this file. The block below is the manual escape hatch for projects that need to compose the Hono app themselves (alternate runtimes, non-default middleware ordering, custom layout injection). For everything else, just author src/api.ts for your REST routes and let the plugin generate the entry.
import { Hono } from 'hono';
import { h } from 'preact';
import { LocationProvider } from 'preact-iso';
import { Routes } from 'hono-preact';
import {
pageActionHandler,
loadersHandler,
renderPage,
routeServerModules,
} from 'hono-preact/server';
import Layout from './Layout.js';
import routes from './routes.js';
import appConfig from './app-config.js';
export const app = new Hono();
const serverModules = routeServerModules(routes);
app
.post('/__loaders', loadersHandler(serverModules))
.on(['POST'], '*', pageActionHandler(serverModules))
.get('/api/movies', async (c) => {
const movies = await getMovies();
return c.json(movies);
})
.get('*', (c) =>
renderPage(
c,
h(Layout, null, h(LocationProvider, null, h(Routes, { routes }))),
{ defaultTitle: 'My App', appConfig }
)
);
If your project has no src/app-config.ts, omit the import and the appConfig field; renderPage treats a missing config as empty (no middleware, no speculation). Add the file when you adopt features that read it.
routeServerModules(routes) is built once at module scope so every request reuses the same record. renderPage synchronously primes globalThis.location immediately before prerender, so no location middleware is needed: concurrent SSR renders cannot trample each other's URL.