Advanced Server Rendering | React Query with Next.js App Router

RMAG news

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

What is Server Rendering?

Server rendering is generating the initial HTML on the server so users see some content immediately when the page loads. This can be done in two ways:

Server-Side Rendering (SSR): Generates HTML on the server each time a page is requested.

Static Site Generation (SSG): Pre-generates HTML at build time or uses cached versions from previous requests.

Why is Server Rendering Useful?

With client rendering, the process looks like this:

Load markup without content.
Load JavaScript.
Fetch data with queries.

This requires at least three server roundtrips before the user sees any content.

Server rendering simplifies this process:

Load markup with content and initial data.
Load JavaScript.

The user sees content as soon as step 1 is complete, and the page becomes interactive after step 2. The initial data is already included in the markup, so there’s no need for an extra data fetch initially!

How Does This Relate to React Query?

Using React Query you can Prefetch data before generating/rendering the markup On the server then use the data on the client to avoid a new fetch.
Now how to implement these steps..

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.

store.tsx:

use client;
import { useState } from react;
import { QueryClient, QueryClientProvider } from @tanstack/react-query;

export default function Store({ children }: { children: React.ReactNode }) {
const [queryClient] = useState(
() =>
new QueryClient({
defaultOptions: {
queries: {
staleTime: 5 * 60 * 1000,
},
},
}),
);
return (
<QueryClientProvider client={queryClient}>
{children}
</QueryClientProvider>
);
}

layout.tsx:

import Store from @/provider/store;

export default async function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<html lang=en>
<body>
<Store>{children}</Store>
</body>
</html>
);
}

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 avoids the drawbacks mentioned earlier.

hydration.tsx :

import {
QueryClient,
dehydrate,
HydrationBoundary,
} from @tanstack/react-query;
import getData from @/api/getData;
import React from react;
export default async function Hydration({
children,
}: {
children: React.ReactNode;
}) {
const queryClient = new QueryClient({
defaultOptions: {
queries: {
staleTime: 5 * 60 * 1000, // this sets the cache time to 5 minutes
},
},
});
await Promise.all([
queryClient.prefetchQuery({
queryKey: [profiles, user],
queryFn: () => getData(profiles),
}),
queryClient.prefetchQuery({
queryKey: [permissions, user],
queryFn: () => getData(permissions),
}),
]);
return (
<HydrationBoundary state={dehydrate(queryClient)}>
{children}
</HydrationBoundary>
);
}

The new layout.tsx:

import Hydration from @/provider/hydration;
import Store from @/provider/store;

export default async function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<html lang=en>
<body>
<Store>
<Hydration>{children}</Hydration>
</Store>
</body>
</html>
);
}

at a general level, these are the extra steps:

Create a constant queryClient using new QueryClient(options) (It is important that you set a staleTime otherwise, React Query will refetch the data as soon as it reaches the client).

Use await queryClient.prefetchQuery(…) for each query you want to prefetch.
Use await Promise.all(…) to fetch the queries in parallel when possible.

It’s fine to have queries that aren’t prefetched. These won’t be server-rendered; instead, they will be fetched on the client after the application is interactive. This can be great for content shown only after user interaction or content far down on the page to avoid blocking more critical content.

Wrap your tree with <HydrationBoundary state={dehydrate(queryClient)}> where dehydratedState comes from the framework loader.

An Important Detail

When using React Query with server rendering, there are actually three queryClient instances involved in the process:

Preloading Phase:
Before rendering, a queryClient is created to prefetch data.
Necessary data is fetched and stored in this queryClient.

Server Rendering Phase:
Once data is prefetched, it’s dehydrated (serialized) and sent to the server rendering process.
A new queryClient is created on the server and injected with dehydrated data.
This ensures the server generates fully populated HTML for the client.

Client Rendering Phase:
Dehydrated data is passed to the client.
Another queryClient is created on the client and rehydrated with the data.
This ensures the client starts with the same data, maintaining consistency and skipping initial data fetching.

This ensures all processes start with the same data, so they can return the same markup.

Then with the useQuery() hook, you can use your prefetched queries as you normally would, and the data will be prefetched during the preloading phase. This means that when you use useQuery() to fetch data in your components, React Query will automatically handle the prefetching of that data before the component renders. This helps improve the performance of your application by ensuring that the data is available when needed.

use client;
import getData from @/api/getData;
import { useQuery } from @tanstack/react-query;

const { data: profiles } = useQuery({
queryKey: [profiles, user],
queryFn: () => getData(profiles),
});
const { data: permissions } = useQuery({
queryKey: [permissions, user],
queryFn: () => getData(permissions),
});

High memory consumption on the server

When you create a QueryClient for each request in React Query, it generates an isolated cache specific to that client. This cache remains in memory for a specified period known as the gcTime. If there’s a high volume of requests within that period, it can lead to significant memory consumption on the server.

By default, on the server, gcTime is set to Infinity, meaning manual garbage collection is disabled, and memory is automatically cleared once a request is completed. However, if you set a non-Infinity gcTime, you’re responsible for clearing the cache early to prevent excessive memory usage.

Avoid setting gcTime to 0, as it might cause a hydration error. The Hydration Boundary places necessary data into the cache for rendering. If the garbage collector removes this data before rendering completes, it can cause issues. Instead, consider setting it to 2 * 1000, allowing sufficient time for the app to reference the data.

To manage memory consumption and clear the cache when it’s no longer needed, you can call queryClient.clear() after handling the request and sending the dehydrated state to the client. Alternatively, you can opt for a smaller gcTime to automatically clear memory sooner. This ensures efficient memory usage and prevents memory-related issues on the server.

example:

const queryClient = new QueryClient({
defaultOptions: {
queries: {
gcTime: 2 * 2000, // this sets the garbage collection time to 2 seconds
},
},
});

In wrapping up, React Query simplifies the process of fetching and caching data, especially when dealing with server-side rendering. By fetching data in advance on the server and seamlessly transferring it to the client, React Query ensures a smooth and consistent user experience. Plus, with features like adjusting the memory management settings, developers can fine-tune performance to meet their application’s needs. With React Query, developers can focus on building engaging applications without worrying about complex data management tasks, ultimately delivering faster and more responsive user experiences.