Pokémon SSR — server render + client pagination
A practical recipe: fetch the first page on the server, ship the cache to the client, render it with no loading flash, and let pagination continue on the client from there. Built on the same Pokémon API as the ApiClient and useApiQuery pages.
The ApiClient is server-safe: the network layer uses the global fetch (configurable via fetchFn),
and every window/document/localStorage access is guarded by typeof ... !== 'undefined'. On the
server use MemoryStorage; on the client any storage works (a sync one — Memory/LocalStorage — gives
the instant first render).
The idea
The cache key is endpoint name + params, so each page (offset) is its own cache entry:
offset: 0was fetched on the server → it's in the cache → the first client render is instant;offset: 12was not → cache miss → a normal network request on the client;back to
offset: 0(while the TTL holds) → instant from cache again.
Shared API factory
Use a factory (a fresh instance per server request — don't share one client across requests). The
storage is itself a factory so each init() gets a clean store.
// pokemon.api.ts
import { ApiClient } from 'synapse-storage/api'
import { MemoryStorage } from 'synapse-storage/core'
interface PokemonListApiResponse {
count: number
next: string | null
results: Array<{ name: string; url: string }>
}
export function createPokemonApi() {
return new ApiClient({
storage: () => new MemoryStorage<Record<string, any>>({ name: 'pokemon-api-cache', initialState: {} }),
baseQuery: { baseUrl: 'https://pokeapi.co/api/v2', timeout: 10000 },
cache: { ttl: 120000 },
endpoints: async (create) => ({
getList: create<{ limit: number; offset: number }, PokemonListApiResponse>({
request: (params) => ({ path: '/pokemon', method: 'GET', query: params }),
tags: ['pokemon-list'],
}),
}),
})
}
export type PokemonApiEndpoints = ReturnType<ReturnType<typeof createPokemonApi>['getEndpoints']>Server: warm the cache and dehydrate
// server.tsx
import { renderToString } from 'react-dom/server'
import { createPokemonApi } from './pokemon.api'
import { App } from './App'
const PAGE_SIZE = 12
export async function renderApp() {
const api = createPokemonApi() // per-request instance
await api.init()
// Warm the cache with the first page
await api.request('getList', { limit: PAGE_SIZE, offset: 0 })
// Render: useApiQuery reads the cache synchronously → data lands in the HTML
const html = renderToString(<App endpoints={api.getEndpoints()} />)
// Snapshot the cache for the client
const state = await api.dehydrate()
await api.destroy()
return { html, state }
}
// In your HTML template, inline the snapshot:
// <script>window.__POKEMON_API_STATE__ = ${JSON.stringify(state)}</script>Client: hydrate and render
// client.tsx
import { hydrateRoot } from 'react-dom/client'
import { createPokemonApi } from './pokemon.api'
import { App } from './App'
async function bootstrap() {
const api = createPokemonApi()
await api.hydrate(window.__POKEMON_API_STATE__) // BEFORE init → seeds the cache
await api.init()
hydrateRoot(document.getElementById('root')!, <App endpoints={api.getEndpoints()} />)
}
bootstrap()The component: first page from cache, pagination on the client
// App.tsx
import { useState } from 'react'
import { useApiQuery } from 'synapse-storage/react'
import type { PokemonApiEndpoints } from './pokemon.api'
const PAGE_SIZE = 12
export function App({ endpoints }: { endpoints: PokemonApiEndpoints }) {
const [offset, setOffset] = useState(0)
// offset=0 → instant from the hydrated cache (no loading flash).
// offset=12 → cache miss → network request on the client.
const { data, isLoading, fromCache } = useApiQuery(endpoints.getList, { limit: PAGE_SIZE, offset })
const items = data?.results ?? []
const hasMore = !!data?.next
return (
<div>
<ul>{items.map((p) => <li key={p.name}>{p.name}</li>)}</ul>
{isLoading && <span>Loading…</span>}
{fromCache && <small>from cache</small>}
<button disabled={offset === 0} onClick={() => setOffset((o) => Math.max(0, o - PAGE_SIZE))}>Prev</button>
<button disabled={!hasMore} onClick={() => setOffset((o) => o + PAGE_SIZE)}>Next</button>
</div>
)
}The first page renders identically on the server and on the client's first paint (so React hydration
matches — no mismatch warning). Clicking Next changes offset, which is a new cache key → the client
fetches the next page; Prev returns to a page that's still cached → instant.
Prewarming several pages
Warm more than one page on the server — they all land in the snapshot:
await Promise.all([
api.request('getList', { limit: PAGE_SIZE, offset: 0 }),
api.request('getList', { limit: PAGE_SIZE, offset: PAGE_SIZE }),
])Now offset: 0 and offset: 12 are instant on the client.
Gotchas
No loading flash needs a sync store on the client.
useApiQuery's instant first render usesgetCachedSync(), which only works onMemoryStorage/LocalStorage. WithIndexedDBthe data still comes from the cache, but after one async tick (a briefloading).Cache-key stability server ↔ client. The key includes
cacheableHeaderKeys. If a cache-affecting header (e.g. auth) differs between server and client, the keys diverge and hydration "misses". Exclude such headers for SSR endpoints viaexcludeCacheableHeaderKeys.One instance per request on the server. Never share a single client across requests — use the factory so each request gets its own cache.
Next.js (App Router)
Same flow: on the server (a Server Component or route handler) create the api with MemoryStorage, call
request() to warm it, then dehydrate(). Pass the snapshot into a Client Component that calls
hydrate() before init() and renders the hooks. (dehydrate/hydrate have no React dependency, so
they're safe to call from 'server only' code — mirrors dehydrateModule for synapse modules.)
See also
ApiClient —
dehydrate()/hydrate(),getCachedSync(), caching and tags.useApiQuery — the hook used here.
useApiMutation — writes + cache invalidation.