TanStack
Guides & Concepts

SSR

This guide covers using Solid Query with server rendering, specifically with SolidStart.

If you haven't already, it's worth reading the Prefetching & Router Integration guide first.

Solid Query's hydration model

Solid Query's useQuery is built on top of Solid's own createResource, rather than a dehydrate/hydrate pair you call yourself. That has a consequence for this guide: there is no dehydrate, hydrate, or HydrationBoundary in your application code.

Instead, when useQuery runs on the server:

  1. It resolves the query through the same QueryClient cache a client render would use, with retry forced off and throwOnError forced on — see Error handling below.
  2. The resolved value is a plain, serializable snapshot of the query result (functions like refetch are stripped, since they can't cross the network).
  3. Solid's own resource streaming carries that snapshot down to the client — the same mechanism createResource uses for any async resource, query-related or not.
  4. On the client, once the snapshot arrives, Solid Query hydrates the query cache from it internally, before your component ever sees a loading state for that query.

So the three steps you'd normally implement by hand — prefetch, dehydrate, hydrate — collapse into "call useQuery (or prefetch with queryClient.query), and let Solid's streaming take care of the rest." The tradeoff is that Solid Query decides what gets serialized and when, rather than you calling dehydrate with your own filtering logic.

One consequence worth calling out: a query that resolved on the server, and is read with a staleTime (or without initialData), does not refetch on mount when it hydrates on the client, since Solid Query already knows it has fresh-enough data from the server render. It behaves the same as any other successful query after that: staleTime governs whether it refetches on the next trigger (window focus, a new mount, invalidateQueries, and so on).

Initial setup

As with any server-rendered app, create the QueryClient per request rather than at module scope, so cached data from one visitor's request is never shared with another's:

tsx
// src/app.tsx
import { MetaProvider, Title } from '@solidjs/meta'
import { Router } from '@solidjs/router'
import { FileRoutes } from '@solidjs/start/router'
import { Suspense } from 'solid-js'
import { QueryClient, QueryClientProvider } from '@tanstack/solid-query'

export default function App() {
  // Constructing the QueryClient inside the component (rather than at module
  // scope) means SolidStart creates a fresh one for every request, so data
  // is never shared between users.
  const queryClient = new QueryClient({
    defaultOptions: {
      queries: {
        // staleTime governs refetches from later triggers (a new mount, window
        // focus, and so on) — see Solid Query's hydration model above for why
        // the very first mount after SSR doesn't need it to skip a refetch.
        staleTime: 5 * 1000,
      },
    },
  })

  return (
    <QueryClientProvider client={queryClient}>
      <Router
        root={(props) => (
          <MetaProvider>
            <Title>My App</Title>
            <Suspense>{props.children}</Suspense>
          </MetaProvider>
        )}
      >
        <FileRoutes />
      </Router>
    </QueryClientProvider>
  )
}

The <Suspense> boundary matters: useQuery suspends while a query is pending, both on the server (so SolidStart can wait for it, or stream past it) and on the client (during the very first render, before hydration data arrives).

Using useQuery directly

Because hydration is automatic, the simplest way to get server-rendered data is to just call useQuery where you need it — no separate prefetch step required:

tsx
import { useQuery } from '@tanstack/solid-query'
import { For, Suspense } from 'solid-js'

function Posts() {
  const query = useQuery(() => ({
    queryKey: ['posts'],
    queryFn: fetchPosts,
  }))

  return (
    <Suspense fallback={<div>Loading...</div>}>
      <ul>
        <For each={query.data}>{(post) => <li>{post.title}</li>}</For>
      </ul>
    </Suspense>
  )
}

On the server, this fetches posts, waits for it (because of the surrounding <Suspense>), and streams the resolved value down. On the client, that value hydrates the cache before Posts ever renders a loading state — as long as the client also renders inside a <Suspense> boundary, which the <Suspense> in app.tsx already provides.

Wrap the same query in Solid's ErrorBoundary to handle a failure. On the server, a failed query always throws to the nearest ErrorBoundary — see Error handling below for why, and for what it takes to get the same behavior on the client too:

tsx
import { useQuery } from '@tanstack/solid-query'
import { ErrorBoundary, For, Suspense } from 'solid-js'

function Posts() {
  const query = useQuery(() => ({
    queryKey: ['posts'],
    queryFn: fetchPosts,
  }))

  return (
    <ErrorBoundary
      fallback={(err, reset) => (
        <div>
          <div>Error: {err.message}</div>
          <button
            onClick={async () => {
              await query.refetch()
              reset()
            }}
          >
            Retry
          </button>
        </div>
      )}
    >
      <Suspense fallback={<div>Loading...</div>}>
        <ul>
          <For each={query.data}>{(post) => <li>{post.title}</li>}</For>
        </ul>
      </Suspense>
    </ErrorBoundary>
  )
}

Prefetching with route.load

To prefetch a query from a SolidStart route's load function, call queryClient.query(...):

tsx
import { noop, useQueryClient } from '@tanstack/solid-query'

export const route = {
  load: () => {
    const queryClient = useQueryClient()
    queryClient.query(postsQueryOptions()).catch(noop)
  },
}

export default function Posts() {
  const query = useQuery(() => postsQueryOptions())
  // ...
}

.catch(noop) keeps a failed prefetch from aborting routing — whatever reads the query afterwards (useQuery) will surface the failure on its own. See Error handling below for how that failure surfaces differently on the server and the client.

Sharing the query options object (postsQueryOptions() above) between route.load and the component that reads the data, rather than repeating the queryKey/queryFn in both places, keeps them from drifting apart — see queryOptions.

Streaming with deferStream

By default, a query streams to the client as soon as it resolves on the server. Set deferStream: true to hold the server's response until that query resolves instead — useful for data that has to be present in the very first response, such as SEO meta tags:

tsx
const query = useQuery(() => ({
  queryKey: ['user'],
  queryFn: fetchUser,
  deferStream: true,
}))

deferStream only affects when the server flushes that query's chunk to the client. Setting it on every query on a page makes the server wait for all of them before sending anything.

Error handling

On the server, useQuery always throws when a query fails — regardless of the throwOnError option — so it's caught by the nearest <ErrorBoundary> rather than surfacing as query.isError. This is different from the client, where a failed query reports isError/error unless you opt in to throwing with throwOnError: true. Retries are also disabled on the server, so a query throws on its first failure rather than retrying first.

If a query is read with useQuery on both the server and the client, set throwOnError: true on it explicitly so the client matches what the server already does — otherwise the same failure throws during SSR but only sets isError once it's running on the client:

tsx
const query = useQuery(() => ({
  queryKey: ['user'],
  queryFn: fetchUser,
  throwOnError: true,
}))

A route.load prefetch isn't affected by this server-only override — queryClient.query(...) doesn't take a throwOnError option at all, so it simply rejects when the query fails, the same on the server and the client. Catch it (or let it propagate) however fits the route; see Prefetching with route.load above for the .catch(noop) pattern used when a failed prefetch shouldn't abort routing.

Tips, Tricks and Caveats

Staleness is measured from when the query was fetched on the server

A query's staleness is based on dataUpdatedAt, which is set to when it resolved on the server, not when the client hydrates it. Because staleTime defaults to 0, an SSR'd query without an explicit staleTime is treated as stale as soon as its next trigger (window focus, a new mount past the first one, invalidateQueries, and so on) checks it, and may refetch in the background then — the first mount on the client is still exempt, per Solid Query's hydration model above. Setting a staleTime above 0 (as in the Initial setup example above) avoids this if you don't want it.

High memory consumption on server

Because the QueryClient is created fresh for every request, its cache is held in memory for the duration of that request. Under high request volume this can add up. gcTime defaults to Infinity on the server, so unless you configure it lower, memory for a request's cache isn't reclaimed automatically — in most cases this is fine, since the whole QueryClient instance becomes eligible for garbage collection once the request finishes and nothing references it anymore.