Silgi
Best Practices

SSR Optimization

Prevent refetch waterfalls with server-side prefetching.

When you render on the server, data should already be in the cache when the client hydrates. Otherwise, TanStack Query refetches everything on mount — causing a visible loading flash.

Silgi provides prefetchQueries to solve this in one call.

The problem

Without prefetching, this is what happens:

  1. Server renders the page with loading spinners
  2. HTML is sent to the client
  3. React hydrates
  4. TanStack Query fires all queries
  5. User sees loading spinners until data arrives

With prefetching:

  1. Server fetches all data and seeds the query cache
  2. HTML is sent with the data already rendered
  3. React hydrates with cached data
  4. No loading spinners, no refetch waterfall

Setup

Create a server-side query client

Use createServerClient so prefetching runs in-process — no network round-trip.

server/query.ts
import {  } from 'silgi/client/server'
import {  } from 'silgi/tanstack-query'
import {  } from './router'

export function () {
  const  = (, {
    : () => ({ : getDB() }),
  })
  return ()
}

Prefetch in your server component or loader

app/users/page.tsx
import { dehydrate, HydrationBoundary, QueryClient } from "@tanstack/react-query"
import { createSSRUtils } from "../../server/query"

export default async function UsersPage() {
const queryClient = new QueryClient()
const utils = createSSRUtils()

  // Prefetch the data server-side
  await queryClient.prefetchQuery(
    utils.users.list.queryOptions({ input: { limit: 20 } })
  )

  return (
    <HydrationBoundary state={dehydrate(queryClient)}>
      <UserList />
    </HydrationBoundary>
  )

}
routes/users.tsx
import { dehydrate, HydrationBoundary, QueryClient } from "@tanstack/react-query"
import { createSSRUtils } from "../server/query"
import { createFileRoute } from "@tanstack/react-router"

export const Route = createFileRoute("/users")({
  loader: async () => {
    const queryClient = new QueryClient()
    const utils = createSSRUtils()

    await queryClient.prefetchQuery(
      utils.users.list.queryOptions({ input: { limit: 20 } })
    )

    return { dehydratedState: dehydrate(queryClient) }
  },
  component: UsersPage,
})

function UsersPage() {
  const { dehydratedState } = Route.useLoaderData()
  return (
    <HydrationBoundary state={dehydratedState}>
      <UserList />
    </HydrationBoundary>
  )
}

Use the same query on the client

The client component uses useQuery as normal. Because the data was prefetched, it is immediately available — no loading state.

components/UserList.tsx
'use client'
import { useQuery } from '@tanstack/react-query'
import { utils } from '../client/query'

export function UserList() {
  // Data is already in the cache from SSR prefetching
  const { data: users } = useQuery(utils.users.list.queryOptions({ input: { limit: 20 } }))

  return (
    <ul>
      {users?.map((u) => (
        <li key={u.id}>{u.name}</li>
      ))}
    </ul>
  )
}

The query key must match exactly between server and client. Since both use utils.users.list.queryOptions({ input: { limit: 20 } }), TanStack Query recognizes the prefetched data and skips the refetch.

Prefetching multiple queries

Prefetch several queries in parallel with Promise.all:

await .([
  queryClient.prefetchQuery(utils.users.list.queryOptions({ : { : 20 } })),
  queryClient.prefetchQuery(utils.stats.summary.queryOptions({ :  })),
])

This is faster than sequential prefetching. All queries run concurrently on the server.

When not to prefetch

Skip prefetching for data that:

  • Changes per user interaction — search results, filtered lists
  • Is very large — the dehydrated state is serialized into the HTML
  • Requires client-side context — data that depends on browser state (localStorage, URL params)

For these cases, let TanStack Query fetch on the client as usual.

What's next?

  • TanStack Query — full query utils reference
  • ClientcreateServerClient() for in-process calls
  • Testing — use the same SSR utils in your test suite

On this page