Skip to content

React SSR

@nano_kit/react-ssr is the React adapter for server-side rendering in Nano Kit.

pnpm add @nano_kit/store @nano_kit/router @nano_kit/react @nano_kit/react-router @nano_kit/react-ssr react react-dom
  1. Define your app

    Create an index file that exports routes and pages. This file is the single source of truth consumed by both the server renderer and the Vite plugin.

    // src/index.ts
    import { page, layout, loadable } from '@nano_kit/router'
    import * as Layout from './Layout.jsx'
    export const routes = {
    home: '/',
    about: '/about'
    } as const
    export const pages = [
    layout(Layout, [
    page('home', loadable(() => import('./pages/Home.jsx'))),
    page('about', loadable(() => import('./pages/About.jsx')))
    ])
    ]
    declare module '@nano_kit/router' {
    interface AppContext {
    routes: typeof routes
    }
    }
  2. Set up the Vite plugin

    Add @nano_kit/react-ssr/vite-plugin to your Vite config. No extra configuration is needed for a standard setup — the plugin uses built-in client and renderer templates automatically.

    // vite.config.js
    import { defineConfig } from 'vite'
    import react from '@vitejs/plugin-react'
    import ssr from '@nano_kit/react-ssr/vite-plugin'
    export default defineConfig({
    plugins: [
    react(),
    ssr({ index: 'src/index.ts' })
    ]
    })
    • vite dev — starts the development server with SSR rendering handled in-process.
    • vite build — produces dist/client/ (browser assets) and dist/renderer/ (SSR renderer bundle).
  3. Write your production HTTP server

    For production, write your own HTTP server that imports the built renderer and calls renderer.render(url) for every incoming request:

    // server.js
    import { renderer } from './dist/renderer/index.js'
    // Express example
    app.get('*', async (req, res) => {
    const result = await renderer.render(req.url)
    if (result.redirect) {
    return res.redirect(result.statusCode, result.redirect)
    }
    if (result.html !== null) {
    return res.status(result.statusCode).send(result.html)
    }
    res.status(result.statusCode).send('Not Found')
    })

To customize the HTML output, extend ReactRenderer and override renderToString:

// src/renderer.tsx
import { ReactRenderer, type RenderData } from '@nano_kit/react-ssr/renderer'
import { routes, pages } from './index.js'
class AppRenderer extends ReactRenderer {
renderToString(data: RenderData) {
// call the default implementation or build your own HTML document
return super.renderToString(data)
}
}
export const renderer = new AppRenderer({
base: import.meta.env.BASE_URL,
manifestPath: import.meta.env.MANIFEST,
routes,
pages
})

Then point the plugin to your custom renderer file:

// vite.config.js
ssr({
index: 'src/index.ts',
renderer: 'src/renderer.tsx'
})

To customize client-side hydration, provide your own client entry:

// src/client.tsx
import { hydrateRoot } from 'react-dom/client'
import { InjectionContextProvider } from '@nano_kit/react'
import { App } from '@nano_kit/react-router'
import { ROOT_ID, ready } from '@nano_kit/react-ssr/client'
import { routes, pages } from './index.js'
ready({ routes, pages }).then((context) => {
hydrateRoot(
document.getElementById(ROOT_ID)!,
<InjectionContextProvider context={context}>
<App />
</InjectionContextProvider>
)
})

Then point the plugin to your client file:

// vite.config.js
ssr({
index: 'src/index.ts',
client: 'src/client.tsx'
})