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,
    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' })]
})
  • 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)
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')
})