Skip to content

Prepare For SSR

Client-only apps can keep state in module scope, because there is just one browser session using that code at a time.

Our example is about to move to server rendering, and that changes the problem. A server handles many requests in one process, so shared module state becomes the wrong shape.

Before adding SSR, move shared stores into factories. Nano Kit uses Dependency Injection for this: each render gets its own store graph, and stores stay easy to compose.

The query client becomes a factory first:

import { client, infinites, mutations, ssr } from '@nano_kit/query'
export function Client$() {
return client(
infinites(),
mutations(),
ssr()
)
}

The query client now includes ssr(). This setting lets query cache data be prepared during server rendering and reused during hydration.

Router-derived state moves into a factory too. The router integration provides Location$, and the factory derives params from it:

import { inject } from '@nano_kit/store'
import { Location$, param, searchParam, searchParams } from '@nano_kit/router'
import { type EventCategory, eventCategories } from '#src/services/events'
export function Params$() {
const $location = inject(Location$)
const $searchParams = searchParams($location)
const $slug = param($location, 'slug')
const $q = searchParam($searchParams, 'q', value => value || '')
const $category = searchParam($searchParams, 'category', (value): EventCategory | null => (
eventCategories.includes(value as EventCategory)
? value as EventCategory
: null
))
return {
$slug,
$q,
$category,
$searchParams
}
}

inject reads another factory from the current context. Location$ is provided by the router integration, so the same params code can run during browser navigation and server rendering.

Stores can now ask for the dependencies they need:

export function EventsList$() {
const { infinite } = inject(Client$)
const { $q, $category } = inject(Params$)
const [
fetchNext,
$events,
$eventsError,
$eventsLoading
] = infinite(
EventsKey,
[$q, $category],
lastPage => lastPage.nextCursor,
(q, category, cursor) => fetchEvents({ q, category, cursor })
)
return {
fetchNext,
$events,
$eventsError,
$eventsLoading
}
}

EventsList$ creates the same list query as before, but now it gets infinite, $q, and $category from injected factories instead of module imports.

React components read factory results with useInject:

import { useInject, useSignal } from '@nano_kit/react'
import { EventsList$ } from '#src/stores/events'
export default function Home() {
const {
fetchNext,
$events,
$eventsError,
$eventsLoading
} = useInject(EventsList$)
const data = useSignal($events)
const error = useSignal($eventsError)
const loading = useSignal($eventsLoading)
/* render the page */
}

This changes how stores are created, but not what the UI can do. The app still has the same filters, pages, queries, and mutations. The difference is that the store graph is now scoped to the current injection context, which is what SSR needs.

The app entry can now use the router shape expected by the SSR renderer. Instead of creating $page manually, it exports routes and pages:

import { layout, loadable, page } from '@nano_kit/react-router'
import { routes } from './stores/router'
import * as Layout from './ui/pages/Layout'
import './app.css'
declare module '@nano_kit/router' {
interface AppContext {
routes: typeof routes
}
}
export { routes }
export const pages = [
layout(Layout, [
page('home', loadable(() => import('./ui/pages/Home'))),
page('newEvent', loadable(() => import('./ui/pages/NewEvent'))),
page('event', loadable(() => import('./ui/pages/Event')))
])
]

layout wraps several pages in the same shell. page connects a route name to a page module. loadable keeps page modules lazy, so the browser can load page code on demand and the SSR renderer can still read page exports.

The route type declaration belongs here because the DI-based router components need to know the app route table.

With the router now available through DI, the layout can use the built-in Link component:

import { Link, Outlet, useLinkComponentAriaCurrent, useLinkComponentPreload } from '@nano_kit/react-router'
export default function Layout() {
useLinkComponentPreload(true)
useLinkComponentAriaCurrent()
return (
<div className='app'>
<header>
<Link to='home'>Event Board</Link>
<nav>
<Link to='home'>Events</Link>
<Link to='newEvent'>New event</Link>
</nav>
</header>
<main>
<Outlet />
</main>
</div>
)
}

Outlet renders the active child page inside the layout.

useLinkComponentPreload(true) enables page preloading for the built-in Link. When a user is likely to open a link, the linked page module can be loaded before the click.

useLinkComponentAriaCurrent adds aria-current to active links. The navigation can now expose the current page to assistive technology without custom link logic.