Build awareness and adoption for your software startup with Circuit.

Next.js App Router, GraphQL Codegen & TanStack Query

The launch of Next.js App Router has ushered in a new era of building web applications with React. The introduction of server components and the new data fetching and caching behaviors within "app router" has required the open source community to play catch-up updating tooling we have grown to know and love (this has not been without controversy!).

I've long been a fan of GraphQL Code Generator and the ease with which it was possible to have end-to-end type safety with data fetching. I was curious to see how this great tool along with TanStack Query could fit together with server components and Next.js. TL;DR, there are a few gotchas.

In this article we'll explore how to:

  • Utilize GraphQL Code Generator and the brand new TanStack Query (React Query) V5 to create type safe React hooks that work with the Next.js caching mechanisms
  • Explore fetching in both client and server components
  • Fix an issue when using POST requests with GraphQL with Next.js
  • Explore React's cache function in server components
  • Create a helper function to improve developer experience

Before getting started I highly recommend reading the well written Next.js documentation with special attention to the caching section. Although there isn't time to do a deep dive on caching with Next.js, I'll be writing a follow up article that explores that topic along with data fetching recipes in the near future.

Follow along by pulling or viewing from the repository which includes visual examples of everything below.

GIF showing various visual representations of data fetching and caching. Top left is a static server component that shows data immediately, top right is a dynamic server component that fetches on request and shows a loading state, bottom left is a client component that is hydrated by a server component that does now show a loading state. Bottom right is a client component that makes a get request on mount and shows a loading state.

Visual representation of data fetching methods mentioned bolow

Bootstrap Next.js

npx create-next-app@latest

Make sure to select Yes for the following options:

Would you like to use TypeScript? Yes
Would you like to use App Router? Yes

Setup and install GraphQL Code Generator

npm i graphql
npm i -D @graphql-codegen/cli
npm i -D @graphql-codegen/typescript-operations
npm i -D @graphql-codegen/typescript-react-query
npm i -D @graphql-codegen/add

Create a codegen.ts config in the root of your project

Documentation for general config options & Tanstack Query Plugin

import type { CodegenConfig } from "@graphql-codegen/cli";

const config: CodegenConfig = {
  // Where your GQL schema is located (could also be externally hosted)
  schema: "src/app/api/graphql/route.ts",
  overwrite: true,
  documents: "./src/_/_.gql",
  generates: {
    // Where the generated types and hooks file will be placed
    "./src/remote/gql-generated.ts": {
      plugins: [
        "typescript",
        "typescript-operations",
        "typescript-react-query",
        // Important! The "add" plugin will inject this into our generated file.
        // This extends RequestInit['Headers'] to include the Next.js extended "fetch"
        // options for caching. This will allow for fine grained cache control
        // with our generated hooks.
        {
          add: {
            content: `
type FetchOptions = {
cache?: RequestCache;
next?: NextFetchRequestConfig;
};

            type RequestInit = {
              headers: (HeadersInit & FetchOptions) | FetchOptions;
            };`,
          },
        },
      ],
      config: {
        // Needed to support the updated React Query 5 API
        reactQueryVersion: 5,
        legacyMode: false,
        exposeFetcher: true,
        exposeQueryKeys: true,
        addSuspenseQuery: true,
        // Allows us to specify a custom fetcher function that will leverage
        // Next.js caching fetaures within our generated query hooks.
        fetcher: "./fetcher#fetcher",
      },
    },
  },
};
export default config;

Add the following "codegen"script to package.json

"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start",
"lint": "next lint",
"codegen": "graphql-codegen --config codegen.ts"
},

Create custom fetcher

This will be the fetching function used within our generated hooks. Be sure to modify the fetch url to match where your GraphQL server is hosted. For demonstration purposes, we are using a server within a Next Route Handler.

The key piece is the passing through of **next** and **cache** options to fetch.

Next.js extends the native [fetch](https://developer.mozilla.org/docs/Web/API/Fetch_API) Web API to allow you to configure the caching and revalidating behavior for each fetch request on the server.

// src/remote/fetcher.ts

type FetchOptions = {
  cache?: RequestCache;
  next?: NextFetchRequestConfig;
};

type RequestInit = {
  headers: (HeadersInit & FetchOptions) | FetchOptions;
};

export const fetcher = <TData, TVariables>(
  query: string,
  variables?: TVariables,
  options?: RequestInit["headers"]
) => {
  return async (): Promise<TData> => {
    const { next, cache, ...restOptions } = options || {};
    const res = await fetch("http://localhost:3000/api/graphql", {
      method: "POST",
      headers: {
        "Content-Type": "application/json",
        ...restOptions,
      },
      body: JSON.stringify({ query, variables }),
      next,
      cache,
    });

    const json = await res.json();

    if (json.errors) {
      const { message } = json.errors[0];

      throw new Error(message);
    }

    return json.data;
  };
};

Setup Tanstack Query

Create QueryClientProvider

// src/app/providers.tsx

"use client";

import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { ReactNode, useState } from "react";

export default function Providers({ children }: { children: ReactNode }) {
  const [queryClient] = useState(() => new QueryClient());

return (

{" "}

<QueryClientProvider client={queryClient}>{children}</QueryClientProvider> ); }

Wrap children in the root layout

// src/app/layout.tsx

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

Add query

Earlier we specified within codegen.ts that our documents would have a.gql file extension, however this could also be.tsx if you have GraphQL documents within typescript files.

// src/remote/queries/hello.gql

query Hello($name: String) {
hello(name: $name)
}

Add GraphQL server implementation (optional)

Only necessary if you do not have an already existing GraphQL server you are trying to access. For the purposes of this demo we are creating a basic Apollo server via the "@as-integrations/next" library. Although resolvers and typedefs are colocated here, I would generally recommend separating them into separate files for maintainability.

// src/app/api/graphql/route.ts

import { startServerAndCreateNextHandler } from "@as-integrations/next";
import { ApolloServer } from "@apollo/server";
import { gql } from "graphql-tag";
import { NextRequest } from "next/server";
import { HelloQueryVariables } from "@/gql-generated";

const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));

const resolvers = {
  Query: {
    hello: async ({ name }: HelloQueryVariables) => {
      // simulate slower network response
      await sleep(2000);
      return `Hello ${name}`;
    },
  },
};

const typeDefs = gql`
  type Query {
    hello(name: String): String!
  }
`;

const server = new ApolloServer({
  resolvers,
  typeDefs,
});

const handler = startServerAndCreateNextHandler<NextRequest>(server);

export async function GET(request: NextRequest) {
  return handler(request);
}

export async function POST(request: NextRequest) {
  return handler(request);
}

Fetching Examples

To see things begin to come together, run npm run codegen 🚀

You should now see a beautifully generated typesafe hook wrapping our custom fetch function in gql-generated.ts. You'll also see fetcher and getKey functions which we'll use for data fetching with our server components later on.

// src/remote/gql-generated.ts

export const useHelloQuery = <TData = HelloQuery, TError = unknown>(
  variables?: HelloQueryVariables,
  options?: Omit<UseQueryOptions<HelloQuery, TError, TData>, "queryKey"> & {
    queryKey?: UseQueryOptions<HelloQuery, TError, TData>["queryKey"];
  }
) => {
  return useQuery<HelloQuery, TError, TData>({
    queryKey: variables === undefined ? ["Hello"] : ["Hello", variables],
    queryFn: fetcher<HelloQuery, HelloQueryVariables>(HelloDocument, variables),
    ...options,
  });
};

useHelloQuery.getKey = (variables?: HelloQueryVariables) =>
  variables === undefined ? ["Hello"] : ["Hello", variables];

useHelloQuery.fetcher = (
  variables?: HelloQueryVariables,
  options?: RequestInit["headers"]
) =>
  fetcher<HelloQuery, HelloQueryVariables>(HelloDocument, variables, options);

Client Component Fetching

Fetching in "client" components is straight forward, we import the useHelloQuery hook and use our typed data response.

// src/app/\_components/ClientComponent.tsx

"use client";

import { useHelloQuery } from "@/remote/gql-generated";

export function ClientComponent() {
  const { data, isLoading } = useHelloQuery({ name: "Client Component" });
  return <p>{isLoading ? "loading..." : data?.hello}</p>;
}

Server Component Fetching

Fetching in server components is a little more involved as we cannot use the useHelloQuery which only works in client components.

// src/app/\_components/ServerComponent.tsx

import { useHelloQuery, HelloQuery } from "@/gql-generated";
import { QueryClient } from "@tanstack/react-query";

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

  const data = await queryClient.fetchQuery<HelloQuery>({
    queryKey: useHelloQuery.getKey(),
    queryFn: useHelloQuery.fetcher({ name: "John Snow" }),
  });

  return <p>{hello}</p>;
}

In this example we're instantiating a new QueryClient from which we are calling fetchQuery and manually passing in the getKeyand fetcherfunctions that are generated via code generation. We are also passing in the generated HelloQuery type into the generic slot of fetchQuery so that our return is typed appropriately.

To make the above code less verbose and to avoid the need to import types, I created a generic function that wraps exported query hooks.

import {
  QueryClient,
  QueryKey,
  UseQueryOptions,
  UseQueryResult,
} from "@tanstack/react-query";

type Exact<T extends { [key: string]: unknown }> = {
  [K in keyof T]: T[K];
};

interface QueryWithKey<TData = any, TVariables = any> {
  (
    variables?: TVariables,
    options?: Omit<UseQueryOptions<TData, unknown, TData>, "queryKey"> & {
      queryKey?: UseQueryOptions<TData, unknown, TData>["queryKey"];
    }
  ): UseQueryResult<TData, unknown>;
  getKey: (variables?: any) => QueryKey;
  fetcher: (variables?: any, options?: any) => () => Promise<TData>;
}

type QueryParams<TQuery extends QueryWithKey> = Parameters<TQuery>[0];

const hasVariablesTypeGuard = <TQuery extends QueryWithKey>(
  variables?: ServerPreFetchOptions<TQuery>
): variables is {
  variables: QueryParams<TQuery>[0];
  next?: NextFetchRequestConfig;
} => !!Object.keys(variables || {}).length;

type ServerPreFetchOptions<TQuery extends QueryWithKey> =
  Parameters<TQuery>[0] extends
    | Exact<{
        [key: string]: never;
      }>
    | undefined
    ? { next?: NextFetchRequestConfig; cache?: RequestCache }
    : {
        variables?: Parameters<TQuery>[0];
        next?: NextFetchRequestConfig;
        cache?: RequestCache;
      };

type FetcherReturnValue<TQuery extends QueryWithKey> = Awaited<
  ReturnType<ReturnType<TQuery["fetcher"]>>
>;

export const serverFetch = async <TQuery extends QueryWithKey>(
  useQuery: TQuery,
  queryOptions?: ServerPreFetchOptions<TQuery>
) => {
  const hasVariables = hasVariablesTypeGuard<TQuery>(queryOptions);
  let variables: QueryParams<TQuery> | undefined;
  if (hasVariables) {
    variables = queryOptions.variables;
  }
  const queryClient = new QueryClient();
  const data = await queryClient.fetchQuery<
    FetcherReturnValue<TQuery>,
    QueryParams<TQuery>
  >({
    queryKey: useQuery.getKey(variables),
    queryFn: useQuery.fetcher(variables, {
      next: queryOptions?.next,
      cache: queryOptions?.cache,
    }),
  });
  return data;
};

This helper function will infer the return and input types based on our generated query hooks. Just pass in useHelloQueryand the serverFetch function will instantiate a query client as well as infer the return and variable types.

import { serverFetch } from "@/remote/query-utils";
import { useHelloQuery } from "@/remote/gql-generated";

// Input / outputs are typed based on "useHelloQuery"
export async function ServerComponent() {
  const { hello } = await serverFetch(useHelloQuery, {
    variables: { name: "from Static Server Component" },
    next: { revalidate: 5 },
  });

  return <p>{hello}</p>;
}

We can also pass in next specific caching options such as "revalidate" to serverFetch. The above query will only hit our data source if more than 5 seconds has elapsed from the last data response timestamp.

Looking better! However there is still one major problem that needs to be resolved.

React extends the [fetch](https://nextjs.org/docs/app/building-your-application/caching#fetch) API to automatically memoize requests that have the same URL and options. This means you can call a fetch function for the same data in multiple places in a React component tree while only executing it once.

Diagram showing how multiple fetch requests to the same endpoint are deduplicated

Diagram from Next.js documentation

This is fantastic as we can safely call the same endpoint in multiple server components without risk of duplicate requests. There is one major caveat however, only "GET" requests are deduplicated.

Although the GraphQL spec is agnostic of which HTTP verb is used for requests, most GraphQL servers follow the convention of accepting "POST" requests. This means our GraphQL implementation which uses "POST" is not handling duplicate requests.

To see this occuring you can add the following to next.config.js

@type {import('next').NextConfig};
const nextConfig = {
  logging: {
    fetches: {
      fullUrl: true,
    },
  },
};

module.exports = nextConfig;

In your terminal you should now be able to see server side API calls as well as cache hits / misses to the persistent Data Cache.

POST /api/graphql 200 in 2020ms
POST /api/graphql 200 in 2021ms
GET / 200 in 2121ms
│ POST http://localhost:3000/api/graphql 200 in 2062ms (cache: SKIP)
│ │ Cache missed reason: (revalidate: 0)

Thankfully React provides a new [cache](https://react.dev/reference/react/cache) function for server components that allows us to memoize function calls during server requests. We can use serverFetch with cache to deduplicate requests with the same inputs.

It's important to note that cache uses inputs as a key to see if the function return has already been cached. Because shallow equality checks are used for inputs, any non primitive values that do not share the same reference will not work.

The variable object that is being passed to serverFetch gets recreated each render and as a result will not pass thecache function shallow equality check. To get around this, we can first stringify the variables object before passing it in. JSON.stringifyfirst re-orders object keys before performing string transformations so we can be sure objects with the same keys and values will pass the equality check. We then later parse those variables back into an object to pass to our function as shows below.

We don't need to worry about the generated query hook that gets passed in as it will be referentially stable across our application.

// src/remote/query-utils.ts

// Receives referentially stable query hook and stringified variables
const cachedServerFetch = cache(
  async <TQuery extends QueryWithKey>(
    useQuery: TQuery,
    stringifiedQueryOptions?: string
  ) => {
    // parses variables back into an object
    const queryOptions = stringifiedQueryOptions
      ? JSON.parse(stringifiedQueryOptions)
      : undefined;
    const hasVariables = hasVariablesTypeGuard<TQuery>(queryOptions);
    let variables: QueryParams<TQuery> | undefined;
    if (hasVariables) {
      variables = queryOptions.variables;
    }
    const queryClient = new QueryClient();
    const data = await queryClient.fetchQuery<
      FetcherReturnValue<TQuery>,
      QueryParams<TQuery>
    >({
      queryKey: useQuery.getKey(variables),
      queryFn: useQuery.fetcher(variables, {
        next: queryOptions?.next,
        cache: queryOptions?.cache,
      }),
    });
    return data;
  }
);

export const serverFetch = async <TQuery extends QueryWithKey>(
  useQuery: TQuery,
  queryOptions?: ServerFetchOptions<TQuery>
) => {
  let stringifiedQueryOptions: string | undefined;
  // Stringify queryOptions before passing to cached function
  if (queryOptions) stringifiedQueryOptions = JSON.stringify(queryOptions);
  return cachedServerFetch(useQuery, stringifiedQueryOptions);
};

And that's it! We can now make type safe GraphQL calls that are deduplicated across our application while having access to next and cache options.

const { hello } = await serverFetch(useHelloQuery, {
  variables: { name: "John Snow" },
  next: { revalidate: 5 },
});

It's also a common pattern to prefetch data in a server component in oder to hydrate the client cache so that data is immediately available when client components access the same query upon mounting. I created a similar serverPrefetch for this purpose and provided an example here.

I'll be writing a follow up article to go further in depth on the various data fetching recipes and when they are best suited. Be sure to subscribe if you would like to be notified of when that is available.

Thanks for making this far!

Further Reading




Continue Learning