Skip to content

Getting Started

@nano_kit/ssr is the base package for server-side rendering in Nano Kit. It provides the core rendering infrastructure — Vite manifest parsing, virtual navigation, store dehydration, and a Vite plugin — that adapter packages build on top of.

pnpm add @nano_kit/store @nano_kit/router @nano_kit/ssr

Every SSR app needs a shared 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. It is also the right place to declare global TypeScript types for route names.

// src/index.ts
import { page, layout, loadable } from '@nano_kit/router'
import * as Layout from './Layout.js'
export const routes = {
home: '/',
about: '/about',
user: '/users/:id'
} as const
export const pages = [
layout(Layout, [
page('home', loadable(() => import('./pages/Home.js'))),
page('about', loadable(() => import('./pages/About.js'))),
page('user', loadable(() => import('./pages/User.js')))
])
]
declare module '@nano_kit/router' {
interface AppContext {
routes: typeof routes
}
}

An adapter consists of three parts: a renderer, a client entry, and a Vite plugin.

  1. Extend the Renderer

    Subclass Renderer from @nano_kit/ssr/renderer and implement the abstract renderToString method. Inject the framework-specific compose function from your router integration by passing it to super().

    // src/renderer/index.js
    import { Renderer, headDescriptorToHtml, ROOT_ID } from '@nano_kit/ssr/renderer'
    import { compose } from 'your-framework-router'
    import { get } from '@nano_kit/store'
    export class FrameworkRenderer extends Renderer {
    constructor(options) {
    super({ ...options, compose })
    }
    renderToString(data) {
    let lang, dir, title, head = ''
    data.head.forEach((descriptor) => {
    if (descriptor.tag === 'lang') lang = get(descriptor.value) || undefined
    else if (descriptor.tag === 'dir') dir = get(descriptor.value) || undefined
    else if (descriptor.tag === 'title') title = get(descriptor.value) || undefined
    else head += headDescriptorToHtml(descriptor)
    })
    if (title) head = `<title>${title}</title>${head}`
    const body = yourFrameworkRenderToString(data.context)
    return `<html lang="${lang}" dir="${dir}"><head>${head}</head><body><div id="${ROOT_ID}">${body}</div><script>${this.dehydratedScript(data.dehydrated)}</script></body></html>`
    }
    }

    Also provide a default renderer template that the Vite plugin uses when the user does not supply a custom one:

    // src/renderer.js
    import { FrameworkRenderer } from 'your-framework-ssr/renderer'
    import { routes, pages } from 'virtual:app-index'
    export const renderer = new FrameworkRenderer({
    base: import.meta.env.BASE_URL,
    manifestPath: import.meta.env.MANIFEST,
    inject: import.meta.env.SSR_INJECT,
    routes,
    pages
    })
  2. Create the Client Entry

    Wrap the base ready function from @nano_kit/ssr/client, injecting the framework-specific router:

    // src/client/index.js
    import { router } from 'your-framework-router'
    import { ready as baseReady } from '@nano_kit/ssr/client'
    export * from '@nano_kit/ssr/client'
    export function ready(options) {
    return baseReady({ ...options, router })
    }

    Also provide a default client template that the Vite plugin uses when the user does not supply a custom one:

    // src/client.js
    import { ROOT_ID, ready } from 'your-framework-ssr/client'
    import { routes, pages } from 'virtual:app-index'
    ready({ routes, pages }).then((context) => {
    // hydrate the app — framework-specific
    yourFrameworkHydrate(document.getElementById(ROOT_ID), context)
    })
  3. Create the Vite Plugin

    Pass a SsrPluginAdapter to the base SsrPlugin from @nano_kit/ssr/vite-plugin. The adapter tells the plugin how to load the default client and renderer templates:

    // src/vite-plugin/index.js
    import path from 'node:path'
    import fs from 'node:fs/promises'
    import SsrPlugin from '@nano_kit/ssr/vite-plugin'
    const adapter = {
    clientPath: 'virtual-client.js',
    rendererPath: 'virtual-renderer.js',
    loadClient() {
    return fs.readFile(path.join(import.meta.dirname, '..', 'client.js'), 'utf-8')
    },
    loadRenderer() {
    return fs.readFile(path.join(import.meta.dirname, '..', 'renderer.js'), 'utf-8')
    }
    }
    export default function FrameworkSsrPlugin(options) {
    return SsrPlugin(options, adapter)
    }

    The plugin automatically handles building both the client bundle and the SSR renderer bundle.

Once an adapter package is published, end users configure their project like this:

// vite.config.js
import { defineConfig } from 'vite'
import ssr from 'your-framework-ssr/vite-plugin'
export default defineConfig({
plugins: [ssr({ index: 'src/index.js' })]
})

The SSR plugin can provide request-bound browser-like dependencies for stores that run during server rendering.

Use inject.cookieStore: true when the renderer should provide a request-bound CookieStore$ during SSR. This feature uses the optional peer package @nano_kit/platform-web, so install it in the app before enabling the option. See SSR Cookies for the full setup, including store code and Set-Cookie forwarding.

ssr({
index: 'src/index.js',
inject: {
cookieStore: true
}
})

Use inject.browserLocale: true when the renderer should provide request-bound Locales$ during SSR. This feature uses the optional peer package @nano_kit/platform-web, so install it in the app before enabling the option. The renderer parses the incoming Accept-Language header with parseLocales, so universal stores can inject Locales$ and resolve the same locale shape on the server and in the browser. See SSR Locale for the full setup.

ssr({
index: 'src/index.js',
inject: {
browserLocale: true
}
})

Use inject.userAgent: true when the renderer should provide request-bound UserAgent$ during SSR. This feature uses the optional peer package @nano_kit/platform-web, so install it in the app before enabling the option. The renderer reads the incoming User-Agent header, so universal stores can inject UserAgent$ and read a user agent string on the server and in the browser.

ssr({
index: 'src/index.js',
inject: {
userAgent: true
}
})
  • vite dev — starts the development server with SSR rendering handled in-process via Vite’s dev middleware.
  • vite build — produces two output directories: dist/client/ (browser assets) and dist/renderer/ (SSR renderer bundle).

For production, you need to write your own HTTP server that imports the renderer bundle and serves the HTML:

// server.js
import { renderer } from './dist/renderer/index.js'
app.get('*', async (req, res) => {
const result = await renderer.render(req.url, {
cookie: req.headers.cookie,
acceptLanguage: req.headers['accept-language'],
userAgent: req.headers['user-agent']
})
if (result.setCookieHeaders) {
res.setHeader('Set-Cookie', result.setCookieHeaders)
}
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')
})