GraphQL Caching Strategies in Next.js Applications
How to implement effective GraphQL caching with Apollo Client, SWR, and Next.js App Router for optimal performance
GraphQL Caching Strategies in Next.js Applications
Introduction
GraphQL is powerful but introduces caching challenges that REST APIs do not have. With REST, URLs are natural cache keys. With GraphQL, every query goes to the same endpoint, making HTTP-level caching impossible without additional tooling.
After migrating a REST API to GraphQL on a Next.js 14 project, I discovered that naive GraphQL implementation doubled the number of API calls and significantly degraded page load performance. The fix required a multi-layered caching strategy that operates at the HTTP, application, and UI levels.
Environment
- OS: Windows 11
- Node.js: v20.10.0
- Next.js: 14.0.4
- GraphQL Client: Apollo Client 3.8.10
- Graphql Yoga: 5.1.1
Problem
Without caching, every page navigation triggered fresh GraphQL queries:
// Each component makes its own query
function ProductList() {
const { data } = useQuery(GET_PRODUCTS) // Always fetches fresh
}
function ProductDetail({ id }: { id: string }) {
const { data } = useQuery(GET_PRODUCT, { variables: { id } }) // Always fetches fresh
}Network tab showed redundant requests:
POST /graphql 200 150ms {"query":"GET_PRODUCTS"}
POST /graphql 200 200ms {"query":"GET_PRODUCT","variables":{"id":"123"}}
POST /graphql 200 180ms {"query":"GET_PRODUCT","variables":{"id":"123"}} // Duplicate!The same product was fetched three times across different components on the same page.
Analysis
GraphQL caching challenges:
1. No natural cache key. Every request is a POST to the same URL, making HTTP caching useless.
2. Normalized data fetching. Different components may request overlapping fields of the same entity, creating duplicate network requests.
3. Stale data. Without cache invalidation strategy, users see outdated content after mutations.
4. Server-side vs client-side caching. In App Router, Server Components and Client Components need different caching approaches.
Solution
Layer 1: Apollo Client cache configuration
// lib/apollo-client.ts
import { ApolloClient, InMemoryCache, HttpLink } from '@apollo/client'
function createApolloClient() {
return new ApolloClient({
link: new HttpLink({
uri: '/api/graphql',
credentials: 'same-origin',
}),
cache: new InMemoryCache({
typePolicies: {
Query: {
fields: {
products: {
keyArgs: ['category', 'sort'],
merge(existing, incoming, { args }) {
if (!args?.offset) return incoming
return {
...incoming,
products: [...(existing?.products || []), ...incoming.products],
}
},
},
product: {
// Cache individual products by ID
read(existing, { args, toReference }) {
return existing || toReference({
__typename: 'Product',
id: args?.id,
})
},
},
},
},
Product: {
keyFields: ['id'], // Cache products by ID
},
},
}),
defaultOptions: {
watchQuery: {
fetchPolicy: 'cache-and-network',
nextFetchPolicy: 'cache-first',
},
},
})
}Layer 2: Server-side query caching with DataLoader
// lib/graphql/dataloader.ts
import DataLoader from 'dataloader'
import { prisma } from '@/lib/prisma'
function createLoaders() {
return {
productLoader: new DataLoader(async (ids) => {
const products = await prisma.product.findMany({
where: { id: { in: [...ids] } },
})
const productMap = new Map(products.map((p) => [p.id, p]))
return ids.map((id) => productMap.get(id) || new Error(`Product ${id} not found`))
}),
userLoader: new DataLoader(async (ids) => {
const users = await prisma.user.findMany({
where: { id: { in: [...ids] } },
})
const userMap = new Map(users.map((u) => [u.id, u]))
return ids.map((id) => userMap.get(id) || new Error(`User ${id} not found`))
}),
}
}
export { createLoaders } Layer 3: Route-level caching with Next.js
// app/products/[id]/page.tsx
import { getClient } from '@/lib/apollo-rsc'
import { GET_PRODUCT } from '@/graphql/queries'
export const revalidate = 300 // Revalidate every 5 minutes
export default async function ProductPage({
params,
}: {
params: { id: string }
}) {
const client = getClient()
const { data } = await client.query({
query: GET_PRODUCT,
variables: { id: params.id },
fetchPolicy: 'network-only', // Always fetch fresh on server
})
return (
{data.product.name}
{data.product.description}
Price: ${data.product.price}
)
}Layer 4: Client-side SWR for real-time updates
// hooks/useProduct.ts
'use client'
import useSWR from 'swr'
const fetcher = (url: string) =>
fetch(url).then((res) => res.json())
export function useProduct(id: string) {
const { data, error, isLoading, mutate } = useSWR(
`/api/products/${id}`,
fetcher,
{
revalidateOnFocus: true,
revalidateOnReconnect: true,
dedupingInterval: 60000, // Dedupe requests within 1 minute
refreshInterval: 0, // Disable auto-refresh by default
}
)
return {
product: data,
isLoading,
isError: error,
refresh: () => mutate(),
}
}Layer 5: Cache invalidation after mutations
// hooks/useCreateProduct.ts
'use client'
import { useMutation } from '@apollo/client'
import { CREATE_PRODUCT } from '@/graphql/mutations'
import { GET_PRODUCTS } from '@/graphql/queries'
export function useCreateProduct() {
const [createProduct, { loading, error }] = useMutation(CREATE_PRODUCT, {
// Update the cache after mutation
update(cache, { data: { createProduct } }) {
const existingProducts = cache.readQuery({
query: GET_PRODUCTS,
variables: { category: 'all' },
})
if (existingProducts) {
cache.writeQuery({
query: GET_PRODUCTS,
variables: { category: 'all' },
data: {
products: [createProduct, ...existingProducts.products],
},
})
}
},
// Refetch related queries
refetchQueries: [
{ query: GET_PRODUCTS, variables: { category: 'all' } },
],
})
return { createProduct, loading, error }
}Lessons Learned
Implement caching in layers. HTTP caching, Apollo Client cache, server-side DataLoaders, and client-side SWR each serve different purposes.
Use
cache-and-networkas the default fetch policy. It shows cached data immediately while fetching fresh data in the background.Batch GraphQL queries when possible. Use Persisted Queries or query batching to reduce the number of HTTP requests.
Invalidate aggressively after mutations. Stale data is worse than no cache. Use
refetchQueriesafter any write operation.Monitor your cache hit rate. Apollo Client DevTools shows cache statistics that help identify caching opportunities.
This blog does not accept any external sponsorships, affiliate marketing, or ad revenue.