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.
The Vite config gets the React SSR plugin:
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' }) ]})@nano_kit/react-ssr adds a Vite plugin that builds two outputs: the client bundle and the server renderer. The index option points to the file from the previous step: it exports routes and pages.
Page modules can expose the data that SSR must await and dehydrate:
// Home.tsxexport 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:
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$:
// Event.tsxexport 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. You do not need to write a local renderer server just to work on the app.
For production, vite build creates dist/client/ with browser assets and dist/renderer/ with the SSR renderer bundle. The HTTP server is yours: choose the runtime, mount the API, serve static files, and call the built renderer for application routes.
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 './dist/renderer/index.js'import { api } from './api/index.js'
const app = new Hono()
app.use(compress())app.use('/api/*', api())
app.use(`${renderer.base.replace(/(.)\/$/, '$1')}*`, serveStatic({ root: './dist/client'}))
app.get('*', async (c) => { const result = await renderer.render(c.req.url)
if (result.redirect) { return c.redirect(result.redirect, result.statusCode) }
if (result.html !== null) { return c.html(result.html, result.statusCode) }
return c.text('Not Found', result.statusCode)})
serve({ fetch: app.fetch, port: 3001})The server does three things:
- Mounts the API endpoints.
- Serves the built client files.
- 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.