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.
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 getKey
and fetcher
functions 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 useHelloQuery
and 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 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.stringify
first 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