GraphQL in Next.js App Router
Next.js App Router introduces React Server Components (RSC), which change how you fetch data. Server Components can fetch data directly on the server without sending JavaScript to the client. This creates interesting patterns for GraphQL — you can fetch data on the server for initial renders and use Apollo Client on the client for interactive features.
Three Approaches for GraphQL in Next.js
- Server Components + fetch: Use the native fetch API to call GraphQL endpoints directly in Server Components
- Apollo Client (Client Components): Use Apollo Client hooks for interactive, client-side data fetching
- Route Handlers: Build your own GraphQL API endpoint within Next.js using Route Handlers
Approach 1: Server Components with fetch
// lib/graphql.ts — reusable server-side GraphQL fetcher
const GRAPHQL_ENDPOINT = process.env.GRAPHQL_URL || 'http://localhost:4000/graphql';
export async function graphqlFetch<T>(
query: string,
variables?: Record<string, unknown>,
options?: { revalidate?: number; tags?: string[] }
): Promise<T> {
const res = await fetch(GRAPHQL_ENDPOINT, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ query, variables }),
next: {
revalidate: options?.revalidate ?? 60,
tags: options?.tags,
},
});
const json = await res.json();
if (json.errors) {
throw new Error(json.errors[0].message);
}
return json.data as T;
}
// app/posts/page.tsx — Server Component (no "use client" directive)
import { graphqlFetch } from '@/lib/graphql';
interface PostsData {
posts: Array<{
id: string;
title: string;
excerpt: string;
slug: string;
author: { name: string };
publishedAt: string;
}>;
}
export default async function PostsPage() {
const data = await graphqlFetch<PostsData>(
`query GetPosts {
posts(filter: { isPublished: true }) {
id
title
excerpt
slug
author { name }
publishedAt
}
}`,
undefined,
{ revalidate: 60, tags: ['posts'] }
);
return (
<div className="max-w-4xl mx-auto py-12">
<h1 className="text-3xl font-bold mb-8">Blog Posts</h1>
<div className="space-y-6">
{data.posts.map(post => (
<article key={post.id} className="border rounded-lg p-6">
<h2 className="text-xl font-semibold">
<a href={`/posts/${post.slug}`}>{post.title}</a>
</h2>
<p className="text-gray-600 mt-2">{post.excerpt}</p>
<div className="text-sm text-gray-500 mt-3">
By {post.author.name} on {post.publishedAt}
</div>
</article>
))}
</div>
</div>
);
}
Approach 2: Apollo Client in Client Components
// lib/apollo-provider.tsx
'use client';
import { ApolloLink, HttpLink } from '@apollo/client';
import {
ApolloNextAppProvider,
ApolloClient,
InMemoryCache,
SSRMultipartLink,
} from '@apollo/experimental-nextjs-app-support';
function makeClient() {
const httpLink = new HttpLink({
uri: process.env.NEXT_PUBLIC_GRAPHQL_URL,
fetchOptions: { cache: 'no-store' },
});
return new ApolloClient({
cache: new InMemoryCache(),
link:
typeof window === 'undefined'
? ApolloLink.from([
new SSRMultipartLink({ stripDefer: true }),
httpLink,
])
: httpLink,
});
}
export function ApolloWrapper({ children }: { children: React.ReactNode }) {
return (
<ApolloNextAppProvider makeClient={makeClient}>
{children}
</ApolloNextAppProvider>
);
}
// components/SearchPosts.tsx — Client Component
'use client';
import { gql, useLazyQuery } from '@apollo/client';
import { useState } from 'react';
const SEARCH_POSTS = gql`
query SearchPosts($query: String!) {
posts(filter: { search: $query }) {
id
title
excerpt
slug
}
}
`;
export function SearchPosts() {
const [search, setSearch] = useState('');
const [executeSearch, { data, loading }] = useLazyQuery(SEARCH_POSTS);
const handleSearch = (e: React.FormEvent) => {
e.preventDefault();
if (search.trim()) {
executeSearch({ variables: { query: search } });
}
};
return (
<div>
<form onSubmit={handleSearch} className="flex gap-2">
<input
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder="Search posts..."
className="flex-1 px-4 py-2 border rounded-lg"
/>
<button type="submit" className="px-4 py-2 bg-blue-600 text-white rounded-lg">
Search
</button>
</form>
{loading && <p>Searching...</p>}
{data?.posts.map((post: any) => (
<a key={post.id} href={`/posts/${post.slug}`} className="block p-3 border rounded mt-2">
<h3 className="font-medium">{post.title}</h3>
<p className="text-sm text-gray-500">{post.excerpt}</p>
</a>
))}
</div>
);
}
Approach 3: Route Handler as GraphQL API
// app/api/graphql/route.ts
import { ApolloServer } from '@apollo/server';
import { startServerAndCreateNextHandler } from '@as-integrations/next';
import { typeDefs } from '@/lib/schema';
import { resolvers } from '@/lib/resolvers';
import { NextRequest } from 'next/server';
import { PrismaClient } from '@prisma/client';
const prisma = new PrismaClient();
const server = new ApolloServer({
typeDefs,
resolvers,
});
const handler = startServerAndCreateNextHandler<NextRequest>(server, {
context: async (req) => {
const token = req.headers.get('authorization')?.replace('Bearer ', '');
// ... authenticate user
return { prisma, currentUser: null };
},
});
export { handler as GET, handler as POST };
Revalidation and Caching
// app/actions/revalidate.ts
'use server';
import { revalidateTag } from 'next/cache';
export async function revalidatePosts() {
revalidateTag('posts');
}
// After a mutation, trigger revalidation
// components/CreatePostForm.tsx
'use client';
import { useMutation, gql } from '@apollo/client';
import { revalidatePosts } from '@/app/actions/revalidate';
function CreatePostForm() {
const [createPost] = useMutation(CREATE_POST, {
onCompleted: async () => {
// Revalidate server-side cached data
await revalidatePosts();
},
});
// ...
}
Key Takeaways
- Use Server Components for initial data: Fetch with native fetch in RSC for SSR performance and SEO
- Use Apollo Client for interactivity: Client Components with useQuery/useMutation for search, forms, and real-time
- Leverage Next.js caching: Use revalidate and tags for ISR-style GraphQL data
- Route Handlers for API: Build your GraphQL API directly in Next.js if you do not need a separate server