Server-Side Rendering
SSR and hydration setup for Eden TanStack Query
Server-Side Rendering
Eden TanStack Query supports SSR with createEdenOptionsProxy, which creates a server-side proxy for prefetching data that hydrates seamlessly on the client.
createEdenOptionsProxy
createEdenOptionsProxy binds to an Eden client and generates the same query keys as the client-side useEden() hook. Pass its options to QueryClient methods to prefetch data:
import { createEdenOptionsProxy } from 'eden-tanstack-react-query'
const eden = createEdenOptionsProxy<App>({
client: serverClient
})
// Use the same API as useEden()
await queryClient.prefetchQuery(eden.users.get.queryOptions())Astro
The runnable Astro example uses an Astro SSR page and one React client:load island. The page creates a request-local QueryClient, prefetches through createEdenOptionsProxy, and passes its dehydrated state as serializable island props.
Keep QueryClientProvider, EdenProvider, HydrationBoundary, and the components that use them inside the same React island. React context does not cross Astro island boundaries. Create a fresh server QueryClient per request and keep the browser instance stable across renders. A shared stale time prevents an immediate refetch after hydration; mutations invalidate the same list query key.
The example includes a same-origin Elysia API and a Bun start command for the standalone Astro adapter output. Its README covers development, builds, and the shared in-memory demo storage.
Next.js App Router
The runnable Next.js example includes a request-local QueryClient, server prefetching, native HydrationBoundary, and a client mutation that invalidates the list. It runs one Bun Elysia API behind a same-origin Next.js rewrite, so server renders and browser requests share the same demo data. Its README covers the root development and production commands.
Eden clients
These examples import your Elysia app's App type from @/lib/server. Set NEXT_PUBLIC_API_URL to the API URL reachable by the browser and INTERNAL_API_URL to the same API's URL reachable by the Next.js server.
Keep the React context in a client module:
'use client'
import { treaty } from '@elysiajs/eden'
import { createEdenTanStackQuery } from 'eden-tanstack-react-query'
import type { App } from '@/lib/server'
export const { EdenProvider, useEden } = createEdenTanStackQuery<App>()
export const edenClient = treaty<App>(process.env.NEXT_PUBLIC_API_URL!)For cookie authentication with a cross-origin API, pass { fetch: { credentials: 'include' } } as the second argument to treaty. Configure the API's CORS to allow credentials and the Next.js application's exact origin. Cross-origin requests can still be same-site, such as requests between subdomains. If the API is also cross-site, its cookies typically need SameSite=None; Secure, and browser restrictions on third-party cookies still apply. Same-origin requests and APIs without cookie authentication can keep the default configuration.
Define the server factory separately. Install server-only with bun add server-only so Next.js rejects imports of this module from client code.
import 'server-only'
import { treaty } from '@elysiajs/eden'
import type { App } from '@/lib/server'
export function getServerEdenClient() {
return treaty<App>(process.env.INTERNAL_API_URL!)
}This factory makes unauthenticated requests. If your API requires authentication, pass the current request's credentials through your server-side Treaty configuration.
Layout and Providers
import { Providers } from './providers'
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="en">
<body>
<Providers>{children}</Providers>
</body>
</html>
)
}'use client'
import { useState } from 'react'
import { QueryClientProvider } from '@tanstack/react-query'
import { getQueryClient } from '@/lib/query-client'
import { EdenProvider, edenClient } from '@/lib/eden'
export function Providers({ children }: { children: React.ReactNode }) {
const [queryClient] = useState(() => getQueryClient())
return (
<QueryClientProvider client={queryClient}>
<EdenProvider client={edenClient}>
{children}
</EdenProvider>
</QueryClientProvider>
)
}QueryClient Factory
Create a fresh QueryClient on the server for each request. In the browser, reuse one instance across renders.
import { QueryClient } from '@tanstack/react-query'
function makeQueryClient() {
return new QueryClient({
defaultOptions: {
queries: {
staleTime: 60 * 1000,
}
}
})
}
let browserQueryClient: QueryClient | undefined
export function getQueryClient() {
if (typeof window === 'undefined') {
return makeQueryClient()
}
if (!browserQueryClient) {
browserQueryClient = makeQueryClient()
}
return browserQueryClient
}Server Component Prefetching
import { HydrationBoundary, dehydrate } from '@tanstack/react-query'
import { createEdenOptionsProxy } from 'eden-tanstack-react-query'
import { getQueryClient } from '@/lib/query-client'
import { getServerEdenClient } from '@/lib/eden-server'
import type { App } from '@/lib/server'
import { UserList } from './user-list'
export default async function UsersPage() {
const queryClient = getQueryClient()
const serverClient = getServerEdenClient()
const eden = createEdenOptionsProxy<App>({
client: serverClient
})
// Prefetch on server
await queryClient.prefetchQuery(eden.users.get.queryOptions())
return (
<HydrationBoundary state={dehydrate(queryClient)}>
<UserList />
</HydrationBoundary>
)
}'use client'
import { useQuery } from '@tanstack/react-query'
import { useEden } from '@/lib/eden'
export function UserList() {
const eden = useEden()
// Uses prefetched data -- no loading state on initial render
const { data: users } = useQuery(eden.users.get.queryOptions())
return (
<ul>
{users?.map(user => (
<li key={user.id}>{user.name}</li>
))}
</ul>
)
}Prefetching Multiple Queries
export default async function DashboardPage() {
const queryClient = getQueryClient()
const eden = createEdenOptionsProxy<App>({ client: getServerEdenClient() })
await Promise.all([
queryClient.prefetchQuery(eden.users.get.queryOptions()),
queryClient.prefetchQuery(eden.stats.get.queryOptions()),
])
return (
<HydrationBoundary state={dehydrate(queryClient)}>
<Dashboard />
</HydrationBoundary>
)
}Next.js Pages Router
The same pattern works with getServerSideProps when the entire dehydrated state is JSON-compatible, as required by the Next.js Pages props contract:
import type { GetServerSideProps } from 'next'
import { dehydrate, QueryClient } from '@tanstack/react-query'
import { createEdenOptionsProxy } from 'eden-tanstack-react-query'
import { treaty } from '@elysiajs/eden'
import type { App } from '@/lib/server'
export const getServerSideProps: GetServerSideProps = async () => {
const queryClient = new QueryClient()
const serverClient = treaty<App>(process.env.INTERNAL_API_URL!)
const eden = createEdenOptionsProxy<App>({ client: serverClient })
await queryClient.prefetchQuery(eden.users.get.queryOptions())
return {
props: { dehydratedState: dehydrate(queryClient) }
}
}Treaty parses date strings into Date objects by default. dehydrate retains those objects, and Pages Router rejects them in raw props. If your cache contains dates or other non-JSON values, serialize the dehydrated state with a serializer that supports those types before returning it in props. Apply its matching deserializer in _app.tsx before passing the state to HydrationBoundary, during both server rendering and browser hydration.
This whole-state serialization also preserves dates in queryKey. Eden retains Date objects in query-key inputs, so use a Date-aware whole-state serializer when those inputs contain dates.
If only query data needs conversion and the rest of the dehydrated state is JSON-compatible, you can instead pair dehydrate(queryClient, { serializeData }) with HydrationBoundary's options={{ defaultOptions: { deserializeData } }}. Alternatively, configure defaultOptions.dehydrate.serializeData and defaultOptions.hydrate.deserializeData on your QueryClients, as shown in the TanStack Query advanced SSR guide. These hooks transform query data only, not query keys. Configure deserialization for both server rendering and browser hydration. Whole-state serialization already handles query data, so it does not require these hooks as well.
A plain JSON.parse(JSON.stringify(...)) round trip turns dates into strings and loses the runtime types expected by query consumers. Disabling date parsing only on the server also makes hydrated data differ from browser refetches. Keep the response parsing consistent across server and browser clients.
Wrap your app with HydrationBoundary in _app.tsx and provide EdenProvider as shown in the Getting Started guide. See the TanStack Query SSR docs for the full Pages Router setup and serialization options.
TanStack Start
The authenticated Start example includes a same-origin Elysia API, request-local cookie forwarding, native Router/Query hydration, and session switching. Its README has development and single-server build commands.
Use ensureQueryData in route loaders for server-side prefetching:
import { createFileRoute } from '@tanstack/react-router'
import { createEdenOptionsProxy } from 'eden-tanstack-react-query'
import { treaty } from '@elysiajs/eden'
import type { App } from '@/lib/server'
export const Route = createFileRoute('/users')({
loader: async ({ context: { queryClient } }) => {
const serverClient = treaty<App>(import.meta.env.VITE_API_URL)
const eden = createEdenOptionsProxy<App>({ client: serverClient })
await queryClient.ensureQueryData(eden.users.get.queryOptions())
},
component: UsersPage
})See the TanStack Start docs for router and QueryClient setup.