SSR / CSR Pattern
routar’s core insight: define the endpoint spec once, swap the executor per environment.
The pattern
Use dispatchExecutor to pick the right transport at request time — one API client works in both SSR and CSR without duplicate *ServerApi instances.
// remote/lib/executor.ts
import axios from 'axios'
import { createAxiosExecutor } from '@routar/axios'
import { dispatchExecutor } from '@routar/core'
import { createFetchExecutor } from '@routar/core'
const BASE_URL = process.env.NEXT_PUBLIC_API_BASE_URL!
// CSR — shared axios instance with interceptors (localStorage, redirects)
const _clientExecutor = createAxiosExecutor(
axios.create({ baseURL: BASE_URL })
)
// SSR — fetch factory, injects httpOnly cookie per-request
const _fetchExecutor = createFetchExecutor(BASE_URL, {
defaultHeaders: async () => {
const { cookies } = await import('next/headers')
const token = (await cookies()).get('access_token')?.value
return token ? { Authorization: `Bearer ${token}` } : {}
},
})
// Single executor — picks transport at request time
export const apiExecutor = dispatchExecutor(() =>
typeof window === 'undefined' ? _fetchExecutor : _clientExecutor
)// remote/services/todo.ts
import { defineRouter, createApi, endpoint } from '@routar/core'
import { apiExecutor } from '../lib/executor'
const todoRouter = defineRouter('/todos', {
getList: endpoint({ method: 'GET', path: '/', response: TodoListSchema }),
getDetail: endpoint({ method: 'GET', path: '/:id', response: TodoSchema,
request: z.object({ path: z.object({ id: z.number() }) }) }),
})
// One API client — works in both SSR and CSR
export const todoApi = createApi(apiExecutor, todoRouter)
export type TodoApiTypes = ApiTypes<typeof todoApi>Own API routes (no auth difference)
If your endpoint hits your own Next.js Route Handlers and doesn’t need environment-specific auth, a single createFetchExecutor with an absolute URL works in both environments — no dispatchExecutor needed.
// remote/lib/executor.ts
const LOCAL_API_BASE = `${process.env.NEXT_PUBLIC_APP_URL ?? 'http://localhost:3000'}/api`
export const localExecutor = createFetchExecutor(LOCAL_API_BASE)// remote/services/todo.ts
export const todoApi = createApi(localExecutor, todoRouter)With TanStack Query
The api client and query helpers live together in one file per domain. Components only import from the services file, never from executors directly.
queryOptions is the single source of truth for both the key and the queryFn — no separate KEYS object.
// remote/services/todo.ts
import { queryOptions, useMutation, useQueryClient } from '@tanstack/react-query'
import { todoApi } from './todo'
export const todoListQueryOptions = () =>
queryOptions({
queryKey: ['todos', 'list'] as const,
queryFn: () => todoApi.getList({}),
})
export const todoDetailQueryOptions = (id: number) =>
queryOptions({
queryKey: ['todos', 'detail', id] as const,
queryFn: () => todoApi.getDetail({ path: { id } }),
})SSR prefetch — server page
Pass queryOptions(params) directly to prefetchQuery. Because dispatchExecutor picks the fetch transport on the server automatically, no queryFn override is needed.
// app/todos/page.tsx
import { dehydrate, HydrationBoundary } from '@tanstack/react-query'
import { getQueryClient } from '@/utils/get-query-client'
import { todoListQueryOptions } from '@/remote/services/todo'
import { Suspense } from 'react'
export default async function TodosPage() {
const queryClient = getQueryClient()
await queryClient.prefetchQuery(todoListQueryOptions())
return (
<HydrationBoundary state={dehydrate(queryClient)}>
<Suspense fallback={<p>Loading…</p>}>
<TodoListClient />
</Suspense>
</HydrationBoundary>
)
}Client component — useSuspenseQuery
'use client'
import { useSuspenseQuery } from '@tanstack/react-query'
import { todoListQueryOptions } from '@/remote/services/todo'
export function TodoListClient() {
const { data } = useSuspenseQuery(todoListQueryOptions()) // data is always non-nullable
return <ul>{data.map(t => <li key={t.id}>{t.label}</li>)}</ul>
}Dependency direction
Component
└─ services/<domain>.ts (createApi + createQueries colocated)
└─ executor.ts (HTTP, auth, middleware)Each layer only knows about the layer below it. Components never import executors.