nahuel.luca()

How fetch data with NextJS App Router and TanStack Query

Introduction

With the different changes introduced by Nextjs App Router, some stacks or technologies have changed their use with Nextjs. One of them is TanStack Query (formerly React Query), so in this post we will try to find the best way to use TanStack Query with NextJS, let's go there!

Also, keep in mind with the framework as NextJS is not necessary to use tools for fetch data, but it is true that some cases these tools are useful for us.

โ˜๐Ÿพ If you're starting a new application, and you're using a mature framework like Next.js or Remix that has a good story around data fetching and mutations, you probably don't need React Query.

These are the words of TkDodo in his post โ€œYou may not need React Queryโ€.

Another thing, I dont will cover how setup TanStack Query in NextJS 14 if you want know how make it check Advanced Server Rendering

The differents ways for fetch data

I found a three differents way for fetch data using NextJS. We see thats ways and then we comparate the performance and the metrics in our app.

The classic

This approach is the classic client-side data collection approach, which would look something like this:

"use client";

export default function PostClient() {
  const {data} = useQuery({
    queryKey: ["posts-client"],
    queryFn: () =>
      fetch("https://dummyjson.com/posts")
        .then((response) => response.json())
        .then((data) => data.posts),
  });

  return (
    <div className="space-y-4 px-10 md:px-20 xl:px-40 my-20">
      <h1 className="text-2xl font-bold">Post Client Side</h1>
      {data?.map((post: PostType) => <PostCard {...post} key={post.id} />)}
    </div>
  );
}

Using prefetch and Server Components

If you are familiar with framework like Remix you know that in this frameworks exists a function called loader this fuction allow us fetch data in the server and provide this data when the route is rendering. Why is this important to us? Well, because for now we will look at the server components as a fuctions loader.

๐Ÿ“Œ Server Components are another form of "preloading" phase, that can also "preload" (pre-render) parts of a React component tree.

But, what is prefetching and how useful is in these cases?

Well, prefetching allows us to get data ahead of time and makes for a faster user experience. With Tanstack query we have different prefetching patterns:

  1. In event handlers
  2. In components
  3. Via router integration
  4. During Server Rendering (another form of router integration)

We are interested in the fourth Server Rendering, with this approach we decrease the amount of request which translates into less waterfalls, also generally on the server the latency is lower and stable.

On client:

-> Markup (without content)
-> JS
-> Query

On Server:

-> Markup (with content AND initial data)
-> JS

With Server Side rendering the initial data come with markup

So, how implement this with NextJS?

First we create a page route where we prefetch the data:

//posts-prefetch/page.tsx
import {dehydrate, HydrationBoundary, QueryClient} from "@tanstack/react-query";

import {getPosts} from "@/queries/post-queries";

import Posts from "./posts";

export default async function page() {
  const queryClient = new QueryClient();

  await queryClient.prefetchQuery({
    queryKey: ["prefetch-posts"],
    queryFn: getPosts,
  });

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

What is happening here is the following, we define a queryClient and then we use it for prefetching the data. Then we return de HydrationBoundary with the Posts component inside. You may be wondering what is HydrationBoundary or dehydration, well in simple words for generate de markup on the server we need dehydrate the 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.

Ok, let's continue:

//posts-prefetch/posts.tsx
import {useQuery} from "@tanstack/react-query";

import {getPosts} from "@/queries/post-queries";
import {PostType} from "@/types/types";
import PostCardPrefetch from "@/components/cards/post-card-prefetch";

export default function Posts() {
  const {data} = useQuery({queryKey: ["prefetch-posts"], queryFn: getPosts});

  return (
    <div className="space-y-4 px-10 md:px-20 xl:px-40 my-20">
      <h1 className="text-2xl font-bold">Post Server Side</h1>
      {data?.map((post: PostType) => <PostCardPrefetch {...post} key={post.id} />)}
    </div>
  );
}

In this component we use the hook useQuery to obtain the prefetch data.

Use a single queryClient for prefetching

In the previous approach we need create new queryClient for each Server Component that fetches data, the documentation of TanStack query recommend this approach but we have another alternative and this is create a single one that is reused across all Server Components.

// app/getQueryClient.tsx
import { QueryClient } from '@tanstack/react-query'
import { cache } from 'react'

// cache() is scoped per request, so we don't leak data between requests
const getQueryClient = cache(() => new QueryClient())
export default getQueryClient

and then:

import {HydrationBoundary, dehydrate} from "@tanstack/react-query";

import {getPosts} from "@/queries/post-queries";
import {PostType} from "@/types/types";
import PostCardPrefetch from "@/components/cards/post-card-prefetch";

import getQueryClient from "../getQueryClient";

export default async function page() {
  const SingleQueryClient = getQueryClient();

  const posts = await SingleQueryClient.fetchQuery({
    queryKey: ["posts"],
    queryFn: getPosts,
  });

  return (
    <HydrationBoundary state={dehydrate(SingleQueryClient)}>
      <div className="space-y-4 px-10 md:px-20 xl:px-40 my-20">
        <h1 className="text-2xl font-bold">Post Server Side</h1>
        {posts?.map((post: PostType) => <PostCardPrefetch {...post} key={post.id} />)}
      </div>
    </HydrationBoundary>
  );
}

The benefit of this approach is that we can call the getQueryClient() on all of the server components including utility functions. The downside is that every time you call dehydrate(getQueryClient()), you serialize the entire queryClient, including queries that have already been serialized before and are unrelated to the current Server Component which is unnecessary overhead.

๐Ÿ“Œ Next.js already dedupes requests that utilize fetch(), but if you are using something else in your queryFn, or if you use a framework that does not dedupe these requests automatically, using a single queryClient as described above might make sense, despite the duplicated serialization.

Performance and metrics

So, in this test, we deploy an application that makes requests to fetch data in the three different ways we discussed above. We will use google lighthouse and the chrome web vitals extension.

Fetch data on the client:

web vitals: client-side-fetch-vitals

Lighthouse: client-side-fetch-lighthouse

Fetch data on the server with prefetch

web vitals: prefetch-vitals

Lighthouse: prefetch-lighthouse

Fetch data with single queryClient:

web vitals: fetch-single-query-vitals

Lighthouse: fetch-single-query-lighthouse

Final words

As seen there are different ways to do data fetching with NextJS and TanStack Query. Choose the option that best suits your application and your needs and keep programming ๐Ÿค˜.

Here is the link to the deployed application and here is the link to the application code. Without anything else to say, thank you very much for reading this post and I hope you found it useful.

Resourses