Skip to content
Merged
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
193 changes: 192 additions & 1 deletion docs/framework/solid/guides/ssr.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,4 +3,195 @@ id: ssr
title: SSR
---

Will come soon
This guide covers using Solid Query with server rendering, specifically with [SolidStart](https://start.solidjs.com/).

If you haven't already, it's worth reading the [Prefetching & Router Integration](./prefetching.md) guide first.

## Solid Query's hydration model

Solid Query's `useQuery` is built on top of Solid's own [`createResource`](https://docs.solidjs.com/reference/basic-reactivity/create-resource), 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](#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](#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](#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`](./query-options.md).

## 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`](#prefetching-with-routeload) 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](#solid-querys-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.
Loading