Server Rendering & Hydration

· abundance's blog


In this guide you'll learn how to use React Query with server rendering.

See the guide on Prefetching & Router Integration for some background. You might also want to check out the Performance & Request Waterfalls guide before that.

For advanced server rendering patterns, such as streaming, Server Components and the new Next.js app router, see the Advanced Server Rendering guide.

If you just want to see some code, you can skip ahead to the Full Next.js pages router example or the Full Remix example below.

Server Rendering & React Query #

So what is server rendering anyway? The rest of this guide will assume you are familiar with the concept, but let's spend some time to look at how it relates to React Query. Server rendering is the act of generating the initial html on the server, so that the user has some content to look at as soon as the page loads. This can happen on demand when a page is requested (SSR). It can also happen ahead of time either because a previous request was cached, or at build time (SSG).

If you've read the Request Waterfalls guide, you might remember this:

1. |-> Markup (without content)
2.   |-> JS
3.     |-> Query

With a client rendered application, these are the minimum 3 server roundtrips you will need to make before getting any content on the screen for the user. One way of viewing server rendering is that it turns the above into this:

1. |-> Markup (with content AND initial data)
2.   |-> JS

As soon as 1. is complete, the user can see the content and when 2. finishes, the page is interactive and clickable. Because the markup also contains the initial data we need, step 3. does not need to run on the client at all, at least until you want to revalidate the data for some reason.

This is all from the clients perspective. On the server, we need to prefetch that data before we generate/render the markup, we need to dehydrate that data into a serializable format we can embed in the markup, and on the client we need to hydrate that data into a React Query cache so we can avoid doing a new fetch on the client.

Read on to learn how to implement these three steps with React Query.

A quick note on Suspense #

This guide uses the regular useQuery API. While we don't necessarily recommend it, it is possible to replace this with useSuspenseQuery instead as long as you always prefetch all your queries. The upside is that you get to use <Suspense> for loading states on the client.

If you do forget to prefetch a query when you are using useSuspenseQuery, the consequences will depend on the framework you are using. In some cases, the data will Suspend and get fetched on the server but never be hydrated to the client, where it will fetch again. In these cases you will get a markup hydration mismatch, because the server and the client tried to render different things.

Initial setup #

The first steps of using React Query is always to create a queryClient and wrap the application in a <QueryClientProvider>. When doing server rendering, it's important to create the queryClient instance inside of your app, in React state (an instance ref works fine too). This ensures that data is not shared between different users and requests, while still only creating the queryClient once per component lifecycle.

Next.js pages router:

 1// _app.tsx
 2import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
 3
 4// NEVER DO THIS:
 5// const queryClient = new QueryClient()
 6//
 7// Creating the queryClient at the file root level makes the cache shared
 8// between all requests and means _all_ data gets passed to _all_ users.
 9// Besides being bad for performance, this also leaks any sensitive data.
10
11export default function MyApp({ Component, pageProps }) {
12  // Instead do this, which ensures each request has its own cache:
13  const [queryClient] = React.useState(
14    () =>
15      new QueryClient({
16        defaultOptions: {
17          queries: {
18            // With SSR, we usually want to set some default staleTime
19            // above 0 to avoid refetching immediately on the client
20            staleTime: 60 * 1000,
21          },
22        },
23      }),
24  )
25
26  return (
27    <QueryClientProvider client={queryClient}>
28      <Component {...pageProps} />
29    </QueryClientProvider>
30  )
31}

Remix:

 1// app/root.tsx
 2import { Outlet } from '@remix-run/react'
 3import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
 4
 5export default function MyApp() {
 6  const [queryClient] = React.useState(
 7    () =>
 8      new QueryClient({
 9        defaultOptions: {
10          queries: {
11            // With SSR, we usually want to set some default staleTime
12            // above 0 to avoid refetching immediately on the client
13            staleTime: 60 * 1000,
14          },
15        },
16      }),
17  )
18
19  return (
20    <QueryClientProvider client={queryClient}>
21      <Outlet />
22    </QueryClientProvider>
23  )
24}

Get started fast with initialData #

The quickest way to get started is to not involve React Query at all when it comes to prefetching and not use the dehydrate/hydrate APIs. What you do instead is passing the raw data in as the initialData option to useQuery. Let's look at an example using Next.js pages router, using getServerSideProps.

 1export async function getServerSideProps() {
 2  const posts = await getPosts()
 3  return { props: { posts } }
 4}
 5
 6function Posts(props) {
 7  const { data } = useQuery({
 8    queryKey: ['posts'],
 9    queryFn: getPosts,
10    initialData: props.posts,
11  })
12
13  // ...
14}

This also works with getStaticProps or even the older getInitialProps and the same pattern can be applied in any other framework that has equivalent functions. This is what the same example looks like with Remix:

 1export async function loader() {
 2  const posts = await getPosts()
 3  return json({ posts })
 4}
 5
 6function Posts() {
 7  const { posts } = useLoaderData<typeof loader>()
 8
 9  const { data } = useQuery({
10    queryKey: ['posts'],
11    queryFn: getPosts,
12    initialData: posts,
13  })
14
15  // ...
16}

The setup is minimal and this can be a quick solution for some cases, but there are a few tradeoffs to consider when compared to the full approach:

Setting up the full hydration solution is straightforward and does not have these drawbacks, this will be the focus for the rest of the documentation.

Using the Hydration APIs #

With just a little more setup, you can use a queryClient to prefetch queries during a preload phase, pass a serialized version of that queryClient to the rendering part of the app and reuse it there. This avoid the drawbacks above. Feel free to skip ahead for full Next.js pages router and Remix examples, but at a general level these are the extra steps:

An interesting detail is that there are actually three queryClients involved. The framework loaders are a form of "preloading" phase that happens before rendering, and this phase has its own queryClient that does the prefetching. The dehydrated result of this phase gets passed to both the server rendering process and the client rendering process which each has its own queryClient. This ensures they both start with the same data so they can return the same markup.

Server Components are another form of "preloading" phase, that can also "preload" (pre-render) parts of a React component tree. Read more in the Advanced Server Rendering guide.

Full Next.js pages router example #

For app router documentation, see the Advanced Server Rendering guide.

Initial setup:

 1// _app.tsx
 2import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
 3
 4export default function MyApp({ Component, pageProps }) {
 5  const [queryClient] = React.useState(
 6    () =>
 7      new QueryClient({
 8        defaultOptions: {
 9          queries: {
10            // With SSR, we usually want to set some default staleTime
11            // above 0 to avoid refetching immediately on the client
12            staleTime: 60 * 1000,
13          },
14        },
15      }),
16  )
17
18  return (
19    <QueryClientProvider client={queryClient}>
20      <Component {...pageProps} />
21    </QueryClientProvider>
22  )
23}

In each route:

 1// pages/posts.jsx
 2import {
 3  dehydrate,
 4  HydrationBoundary,
 5  QueryClient,
 6  useQuery,
 7} from '@tanstack/react-query'
 8
 9// This could also be getServerSideProps
10export async function getStaticProps() {
11  const queryClient = new QueryClient()
12
13  await queryClient.prefetchQuery({
14    queryKey: ['posts'],
15    queryFn: getPosts,
16  })
17
18  return {
19    props: {
20      dehydratedState: dehydrate(queryClient),
21    },
22  }
23}
24
25function Posts() {
26  // This useQuery could just as well happen in some deeper child to
27  // the <PostsRoute>, data will be available immediately either way
28  const { data } = useQuery({ queryKey: ['posts'], queryFn: getPosts })
29
30  // This query was not prefetched on the server and will not start
31  // fetching until on the client, both patterns are fine to mix
32  const { data: commentsData } = useQuery({
33    queryKey: ['posts-comments'],
34    queryFn: getComments,
35  })
36
37  // ...
38}
39
40export default function PostsRoute({ dehydratedState }) {
41  return (
42    <HydrationBoundary state={dehydratedState}>
43      <Posts />
44    </HydrationBoundary>
45  )
46}

Full Remix example #

Initial setup:

 1// app/root.tsx
 2import { Outlet } from '@remix-run/react'
 3import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
 4
 5export default function MyApp() {
 6  const [queryClient] = React.useState(
 7    () =>
 8      new QueryClient({
 9        defaultOptions: {
10          queries: {
11            // With SSR, we usually want to set some default staleTime
12            // above 0 to avoid refetching immediately on the client
13            staleTime: 60 * 1000,
14          },
15        },
16      }),
17  )
18
19  return (
20    <QueryClientProvider client={queryClient}>
21      <Outlet />
22    </QueryClientProvider>
23  )
24}

In each route, note that it's fine to do this in nested routes too:

 1// app/routes/posts.tsx
 2import { json } from '@remix-run/node'
 3import {
 4  dehydrate,
 5  HydrationBoundary,
 6  QueryClient,
 7  useQuery,
 8} from '@tanstack/react-query'
 9
10export async function loader() {
11  const queryClient = new QueryClient()
12
13  await queryClient.prefetchQuery({
14    queryKey: ['posts'],
15    queryFn: getPosts,
16  })
17
18  return json({ dehydratedState: dehydrate(queryClient) })
19}
20
21function Posts() {
22  // This useQuery could just as well happen in some deeper child to
23  // the <PostsRoute>, data will be available immediately either way
24  const { data } = useQuery({ queryKey: ['posts'], queryFn: getPosts })
25
26  // This query was not prefetched on the server and will not start
27  // fetching until on the client, both patterns are fine to mix
28  const { data: commentsData } = useQuery({
29    queryKey: ['posts-comments'],
30    queryFn: getComments,
31  })
32
33  // ...
34}
35
36export default function PostsRoute() {
37  const { dehydratedState } = useLoaderData<typeof loader>()
38  return (
39    <HydrationBoundary state={dehydratedState}>
40      <Posts />
41    </HydrationBoundary>
42  )
43}

Optional - Remove boilerplate #

Having this part in every route might seem like a lot of boilerplate:

1export default function PostsRoute({ dehydratedState }) {
2  return (
3    <HydrationBoundary state={dehydratedState}>
4      <Posts />
5    </HydrationBoundary>
6  )
7}

While there is nothing wrong with this approach, if you want to get rid of this boilerplate, here's how you can modify your setup in Next.js:

 1// _app.tsx
 2import {
 3  HydrationBoundary,
 4  QueryClient,
 5  QueryClientProvider,
 6} from '@tanstack/react-query'
 7
 8export default function MyApp({ Component, pageProps }) {
 9  const [queryClient] = React.useState(() => new QueryClient())
10
11  return (
12    <QueryClientProvider client={queryClient}>
13      <HydrationBoundary state={pageProps.dehydratedState}>
14        <Component {...pageProps} />
15      </HydrationBoundary>
16    </QueryClientProvider>
17  )
18}
19
20// pages/posts.tsx
21// Remove PostsRoute with the HydrationBoundary and instead export Posts directly:
22export default function Posts() { ... }

With Remix, this is a little bit more involved, we recommend checking out the use-dehydrated-state package.

Prefetching dependent queries #

Over in the Prefetching guide we learned how to prefetch dependent queries, but how do we do this in framework loaders? Consider the following code, taken from the Dependent Queries guide:

 1// Get the user
 2const { data: user } = useQuery({
 3  queryKey: ['user', email],
 4  queryFn: getUserByEmail,
 5})
 6
 7const userId = user?.id
 8
 9// Then get the user's projects
10const {
11  status,
12  fetchStatus,
13  data: projects,
14} = useQuery({
15  queryKey: ['projects', userId],
16  queryFn: getProjectsByUser,
17  // The query will not execute until the userId exists
18  enabled: !!userId,
19})

How would we prefetch this so it can be server rendered? Here's an example:

 1// For Remix, rename this to loader instead
 2export async function getServerSideProps() {
 3  const queryClient = new QueryClient()
 4
 5  const user = await queryClient.fetchQuery({
 6    queryKey: ['user', email],
 7    queryFn: getUserByEmail,
 8  })
 9
10  if (user?.userId) {
11    await queryClient.prefetchQuery({
12      queryKey: ['projects', userId],
13      queryFn: getProjectsByUser,
14    })
15  }
16
17  // For Remix:
18  // return json({ dehydratedState: dehydrate(queryClient) })
19  return { props: { dehydratedState: dehydrate(queryClient) } }
20}

This can get more complex of course, but since these loader functions are just JavaScript, you can use the full power of the language to build your logic. Make sure you prefetch all queries that you want to be server rendered.

Error handling #

React Query defaults to a graceful degradation strategy. This means:

This will lead to any failed queries being retried on the client and that the server rendered output will include loading states instead of the full content.

While a good default, sometimes this is not what you want. When critical content is missing, you might want to respond with a 404 or 500 status code depending on the situation. For these cases, use queryClient.fetchQuery(...) instead, which will throw errors when it fails, letting you handle things in a suitable way.

1let result
2
3try {
4  result = await queryClient.fetchQuery(...)
5} catch (error) {
6  // Handle the error, refer to your framework documentation
7}
8
9// You might also want to check and handle any invalid `result` here

If you for some reason want to include failed queries in the dehydrated state to avoid retries, you can use the option shouldDehydrateQuery to override the default function and implement your own logic:

1dehydrate(queryClient, {
2  shouldDehydrateQuery: (query) => {
3    // This will include all queries, including failed ones,
4    // but you can also implement your own logic by inspecting `query`
5    return true
6  },
7})

Serialization #

When doing return { props: { dehydratedState: dehydrate(queryClient) } } in Next.js, or return json({ dehydratedState: dehydrate(queryClient) }) in Remix, what happens is that the dehydratedState representation of the queryClient is serialized by the framework so it can be embedded into the markup and transported to the client.

By default, these frameworks only supports returning things that are safely serializable/parsable, and therefore does not support undefined, Error, Date, Map, Set, BigInt, Infinity, NaN, -0, regular expressions etc. This also means that you can not return any of these things from your queries. If returning these values is something you want, check out superjson or similar packages.

If you are using a custom SSR setup, you need to take care of this step yourself. Your first instinct might be to use JSON.stringify(dehydratedState), but because this doesn't escape things like <script>alert('Oh no..')</script> by default, this can easily lead to XSS-vulnerabilities in your application. superjson also does not escape values and is unsafe to use by itself in a custom SSR setup (unless you add an extra step for escaping the output). Instead we recommend using a library like Serialize JavaScript or devalue which are both safe against XSS injections out of the box.

A note about request waterfalls #

In the Performance & Request Waterfalls guide we mentioned we would revisit how server rendering changes one of the more complex nested waterfalls. Check back for the specific code example, but as a refresher, we have a code split <GraphFeedItem> component inside a <Feed> component. This only renders if the feed contains a graph item and both of these components fetches their own data. With client rendering, this leads to the following request waterfall:

1. |> Markup (without content)
2.   |> JS for <Feed>
3.     |> getFeed()
4.       |> JS for <GraphFeedItem>
5.         |> getGraphDataById()

The nice thing about server rendering is that we can turn the above into:

1. |> Markup (with content AND initial data)
2.   |> JS for <Feed>
2.   |> JS for <GraphFeedItem>

Note that the queries are no longer fetched on the client, instead their data was included in the markup. The reason we can now load the JS in parallel is that since <GraphFeedItem> was rendered on the server we know that we are going to need this JS on the client as well and can insert a script-tag for this chunk in the markup. On the server, we would still have this request waterfall:

1. |> getFeed()
2.   |> getGraphDataById()

We simply can not know before we have fetched the feed if we also need to fetch graph data, they are dependent queries. Because this happens on the server where latency is generally both lower and more stable, this often isn't such a big deal.

Amazing, we've mostly flattened our waterfalls! There's a catch though. Let's call this page the /feed page, and let's pretend we also have another page like /posts. If we type in www.example.com/feed directly in the url bar and hit enter, we get all these great server rendering benefits, BUT, if we instead type in www.example.com/posts and then click a link to /feed, we're back to to this:

1. |> JS for <Feed>
2.   |> getFeed()
3.     |> JS for <GraphFeedItem>
4.       |> getGraphDataById()

This is because with SPA's, server rendering only works for the initial page load, not for any subsequent navigation.

Modern frameworks often tries to solve this by fetching the initial code and data in parallel, so if you were using Next.js or Remix with the prefetching patterns we outlined in this guide, including how to prefetch dependent queries, it would actually look like this instead:

1. |> JS for <Feed>
1. |> getFeed() + getGraphDataById()
2.   |> JS for <GraphFeedItem>

This is much better, but if we want to improve this further we can flatten this to a single roundtrip with Server Components. Learn how in the Advanced Server Rendering guide.

Tips, Tricks and Caveats #

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

A query is considered stale depending on when it was dataUpdatedAt. A caveat here is that the server needs to have the correct time for this to work properly, but UTC time is used, so timezones do not factor into this.

Because staleTime defaults to 0, queries will be refetched in the background on page load by default. You might want to use a higher staleTime to avoid this double fetching, especially if you don't cache your markup.

This refetching of stale queries is a perfect match when caching markup in a CDN! You can set the cache time of the page itself decently high to avoid having to re-render pages on the server, but configure the staleTime of the queries lower to make sure data is refetched in the background as soon as a user visits the page. Maybe you want to cache the pages for a week, but refetch the data automatically on page load if it's older than a day?

High memory consumption on server #

In case you are creating the QueryClient for every request, React Query creates the isolated cache for this client, which is preserved in memory for the gcTime period. That may lead to high memory consumption on server in case of high number of requests during that period.

On the server, gcTime defaults to Infinity which disables manual garbage collection and will automatically clear memory once a request has finished. If you are explicitly setting a non-Infinity gcTime then you will be responsible for clearing the cache early.

Avoid setting gcTime to 0 as it may result in a hydration error. This occurs because the Hydration Boundary places necessary data into the cache for rendering, but if the garbage collector removes the data before the rendering completes, issues may arise. If you require a shorter gcTime, we recommend setting it to 2 * 1000 to allow sufficient time for the app to reference the data.

To clear the cache after it is not needed and to lower memory consumption, you can add a call to queryClient.clear() after the request is handled and dehydrated state has been sent to the client.

Alternatively, you can set a smaller gcTime.

Caveat for Next.js rewrites #

There's a catch if you're using Next.js' rewrites feature together with Automatic Static Optimization or getStaticProps: It will cause a second hydration by React Query. That's because Next.js needs to ensure that they parse the rewrites on the client and collect any params after hydration so that they can be provided in router.query.

The result is missing referential equality for all the hydration data, which for example triggers wherever your data is used as props of components or in the dependency array of useEffects/useMemos.