Skip to content

Server Rendering

Some pages should be useful before JavaScript finishes loading. Public pages especially benefit from ready HTML, title, and description.

In our example, event pages are public URLs, so they are a good fit for SSR.

The app is already in the shape that Nano Kit SSR needs: stores are factories, and the app entry exports routes and pages. The next step is to teach Vite how to build a renderer from that entry.

Add the React SSR integration and the Hono server runtime:

Terminal window
pnpm add @nano_kit/react-ssr hono @hono/node-server

The Vite config gets the React SSR plugin:

vite.config.ts
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
import ssr from '@nano_kit/react-ssr/vite-plugin'
export default defineConfig({
server: {
proxy: {
'/api': 'http://localhost:3001'
}
},
plugins: [
react(),
ssr({
index: 'src/index.tsx',
server: 'src/server.ts'
})
]
})

@nano_kit/react-ssr adds a Vite plugin that builds the client bundle and the server for you. The index option points to the file from the previous step: it exports routes and pages. The server option points to your production HTTP server file — the plugin bundles it together with the renderer.

Page modules can expose the data that SSR must await and dehydrate:

src/ui/pages/Home.tsx
export function Stores$() {
const { $events } = inject(EventsList$)
return [$events]
}
export function Head$() {
return [
title('Event Board | Upcoming events'),
meta({
name: 'description',
content: 'Find meetups, workshops, webinars, and conferences.'
})
]
}

Stores$ tells the renderer which signals must be loaded and dehydrated for this page. Here the home page waits for $events, so the first HTML response already contains the event list.

Head$ returns title and meta entries for the page.

The layout should sync page head entries during client navigation:

src/ui/pages/Layout.tsx
import { useSyncHead } from '@nano_kit/react-router'
export default function Layout() {
useSyncHead()
/* render links and Outlet */
}

useSyncHead keeps the browser document head in sync when the active page changes.

Detail pages can use loaded data in Head$:

src/ui/pages/Event.tsx
export function Stores$() {
const { $event } = inject(EventDetails$)
return [$event]
}
export function Head$() {
const { $event } = inject(EventDetails$)
return [
title(() => {
const event = $event()
return event
? `${event.title} | Event Board`
: 'Event Board | Event'
}),
meta({
name: 'description',
content: () => $event()?.description ?? 'Event details'
})
]
}

The detail page returns $event from Stores$, so Head$ can use the loaded event title and description.

In development, vite dev handles SSR rendering in-process through the plugin. The server file is not used in dev — it is a production entrypoint.

The server itself is a regular Hono app. It receives the built renderer through the typed virtual:app-renderer virtual module:

src/server.ts
import type { RedirectStatusCode } from 'hono/utils/http-status'
import { serve } from '@hono/node-server'
import { serveStatic } from '@hono/node-server/serve-static'
import { Hono } from 'hono'
import { compress } from 'hono/compress'
import { renderer } from 'virtual:app-renderer'
import { api } from '../api/index.js'
const app = new Hono()
app.use(compress())
app.route('/', api())
app.use(`${renderer.base.replace(/(.)\/$/, '$1')}*`, serveStatic({
root: './dist/client',
onFound: (_, c) => {
c.header('Cache-Control', 'public, immutable, max-age=31536000')
}
}))
app.get('*', async (c) => {
const result = await renderer.render(c.req.url)
if (result.redirect) {
return c.redirect(result.redirect, result.statusCode as RedirectStatusCode)
}
if (result.html !== null) {
return c.html(result.html, result.statusCode)
}
return c.text('Not Found', result.statusCode)
})
serve({
fetch: app.fetch,
port: Number(process.env.PORT || 3001)
})

The api() router here is the in-memory API assumed in Setup — take it from the example source if you are building along. Its standalone api/server.js is also what the dev proxy from the Vite config points at.

The server does three things:

  1. Mounts the API endpoints, so one process serves both the app and the data.
  2. Serves the built client files with an immutable cache header — asset names are content-hashed, so they can be cached forever.
  3. Sends all other requests to renderer.render(...).

If the renderer returns a redirect, the server redirects. If it returns HTML, the server sends it. Otherwise, the server responds with Not Found.

vite build produces dist/client/ with browser assets and dist/server/ with the bundled server. Production start is a single command:

Terminal window
node dist/server/index.js