Next.js matured from a pages-first React framework into a full-stack runtime with Server Components, Route Handlers, and Server Actions. That evolution makes GraphQL a natural fit: you can run a GraphQL server in-process (no extra infra), stream results into React trees, and choose exactly where caching lives. Since Next.js 15, GET Route Handlers and the client router cache are uncached by default, which reduces surprising staleness and gives you explicit control over revalidation for GraphQL traffic. As you’ll see, Apollo Client 4 and URQL 5 both ship first-class patterns for the App Router era, including streaming SSR for Apollo and an SSR exchange for URQL, so you can keep a single query across server and client with predictable cache semantics.
What you’ll build:
You’ll stand up a production-shaped GraphQL endpoint inside app/api/graphql/route.ts
, then consume it from Server Components and Client Components using Apollo Client and URQL. We’ll cover authentication, cache & revalidation, mutations (including Server Actions), and subscriptions.
Assumption: TypeScript, Next.js 15, Node 18+, and a React server runtime (default in the App Router).
Step 1 — Host GraphQL inside Next.js
Why here: Route Handlers live in the App Router, so your GraphQL server shares the same runtime, env, and observability as your UI. You can export both GET
and POST
to support tools and playgrounds.
Option A: GraphQL Yoga (minimal, framework-agnostic).
app/api/graphql/route.ts
import { createSchema, createYoga } from 'graphql-yoga';
const yoga = createYoga({
schema: createSchema({
typeDefs: /* GraphQL */ `
type Query { hello: String! }
type Mutation { ping(message: String!): String! }
`,
resolvers: {
Query: { hello: () => 'Hello from Yoga + Next.js' },
Mutation: { ping: (_: unknown, { message }: { message: string }) => message },
},
}),
graphqlEndpoint: '/api/graphql',
});
export { yoga as GET, yoga as POST };
Yoga cleanly maps to Next’s Web Request API and supports export { yoga as GET, yoga as POST }
out of the box.
Option B: Apollo Server (if you need federation plugins, etc.).
app/api/graphql/route.ts
import { startServerAndCreateNextHandler } from '@as-integrations/next';
import { ApolloServer } from '@apollo/server';
import { gql } from 'graphql-tag';
const typeDefs = gql`
type Query { hello: String! }
type Mutation { ping(message: String!): String! }
`;
const resolvers = {
Query: { hello: () => 'Hello from Apollo Server + Next.js' },
Mutation: { ping: (_: unknown, { message }: { message: string }) => message },
};
const server = new ApolloServer({ typeDefs, resolvers });
export const GET = startServerAndCreateNextHandler(server);
export const POST = GET;
Apollo’s @as-integrations/next
targets Route Handlers directly and works in the App Router.
Caching note. In Next.js 15, GET Route Handlers are uncached by default. If you later enable caching (e.g., for persisted queries), do it intentionally with Response
headers or Next’s revalidate
options.
Step 2 — Apollo Client 4 on the server and client
Apollo’s Next.js integration provides streaming SSR that plays nicely with Server Components and Suspense. The dedicated package is @apollo/client-integration-nextjs
(formerly experimental-nextjs-app-support
) and is now officially released.
Install
pnpm add @apollo/client @apollo/client-integration-nextjs graphql
Apollo Client 4 is the current major.
Wire the Provider (client boundary)
app/providers.tsx
(Client Component)
'use client';
import { ApolloClient, HttpLink, InMemoryCache } from '@apollo/client';
import { ApolloNextAppProvider } from '@apollo/client-integration-nextjs';
function makeClient() {
return new ApolloClient({
cache: new InMemoryCache(),
link: new HttpLink({
uri: '/api/graphql',
// Avoid implicit caching during development; tune per route later.
fetchOptions: { cache: 'no-store' },
credentials: 'include',
}),
// Apollo defaults are fine; add typePolicies as your schema grows.
});
}
export default function Providers({ children }: { children: React.ReactNode }) {
return (
<ApolloNextAppProvider makeClient={makeClient}>
{children}
</ApolloNextAppProvider>
);
}
app/layout.tsx
(Server Component)
import Providers from './providers';
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="en"><body><Providers>{children}</Providers></body></html>
);
}
The provider hydrates Apollo on the client without breaking Server Components.
Query in a Server Component
app/page.tsx
(Server Component)
import { gql } from '@apollo/client';
import { getClient } from '@apollo/client-integration-nextjs/client';
const HELLO = gql`query { hello }`;
export default async function Page() {
const client = getClient();
const { data } = await client.query({
query: HELLO,
context: { fetchOptions: { next: { revalidate: 0 } } }, // explicit: no cache
});
return <pre>{data.hello}</pre>;
}
getClient()
gives you a request-scoped client compatible with RSC and Suspense. You can stream results when queries stall, or opt into caching by setting revalidate
.
Query in a Client Component
app/components/HelloClient.tsx
(Client Component)
'use client';
import { gql, useQuery } from '@apollo/client';
const HELLO = gql`query { hello }`;
export function HelloClient() {
const { data, loading, error } = useQuery(HELLO);
if (loading) return <p>Loading…</p>;
if (error) return <p>Oops: {error.message}</p>;
return <p>{data.hello}</p>;
}
Mutations with or without Server Actions
For client-driven mutations, use useMutation
and update the cache. For server-initiated writes (e.g., privileged headers), create a Server Action that calls the same GraphQL mutation and then returns updated UI—this integrates with Next’s cache invalidation.
// app/actions.ts
'use server';
import { gql } from '@apollo/client';
import { getClient } from '@apollo/client-integration-nextjs/client';
const PING = gql`mutation Ping($message: String!) { ping(message: $message) }`;
export async function sendPing(message: string) {
const { data } = await getClient().mutate({ mutation: PING, variables: { message } });
return data?.ping;
}
Step 3 — URQL 5 on the server and client
URQL focuses on a lightweight core with composable “exchanges.” For Next.js, you’ll use the SSR exchange and the small @urql/next
helpers to hydrate client components cleanly. Version 5 is the current major.
Install
pnpm add urql @urql/next graphql
Build the client + SSR exchange
app/lib/urql.ts
import { cacheExchange, createClient, dedupExchange, fetchExchange, ssrExchange } from 'urql';
export function createUrql(ssr = ssrExchange()) {
const client = createClient({
url: '/api/graphql',
exchanges: [dedupExchange, cacheExchange, ssr, fetchExchange],
suspense: true, // better with RSC/Suspense
fetchOptions: { credentials: 'include' },
});
return { client, ssr };
}
Provide in the App Router
app/urql-provider.tsx
(Client Component)
'use client';
import { Provider as UrqlProvider } from 'urql';
export default function UrqlClientProvider({
children,
urqlState,
url = '/api/graphql',
}: {
children: React.ReactNode;
urqlState: any;
url?: string;
}) {
// Recreate the SSR exchange on the client with initial state:
const { createUrql } = require('./lib/urql');
const { client, ssr } = createUrql();
ssr.restoreData(urqlState);
return <UrqlProvider value={client}>{children}</UrqlProvider>;
}
app/page.tsx
(Server Component)
import UrqlClientProvider from './urql-provider';
import { createUrql } from './lib/urql';
import { gql } from 'urql';
const HELLO = gql`query { hello }`;
export default async function Page() {
const { client, ssr } = createUrql();
await client.query(HELLO, {}).toPromise(); // prefetch on server
return (
<UrqlClientProvider urqlState={ssr.extractData()}>
{/* Client components under here can use useQuery without refetch */}
<section>URQL is hydrated.</section>
</UrqlClientProvider>
);
}
URQL’s SSR docs show this pattern—prefetch on the server, extract, then restore on the client—so client hooks start warm.
Client query with URQL
app/components/HelloUrql.tsx
(Client Component)
'use client';
import { gql, useQuery } from 'urql';
const HELLO = gql`query { hello }`;
export function HelloUrql() {
const [{ data, fetching, error }] = useQuery({ query: HELLO });
if (fetching) return <p>Loading…</p>;
if (error) return <p>Oops: {error.message}</p>;
return <p>{data.hello}</p>;
}
Authentication, headers, and secrets
Prefer routing sensitive headers through your Route Handler, not the browser. For Apollo Client, add auth via HttpLink
or setContext
; for URQL, use @urql/exchange-auth
to attach and refresh tokens. When queries originate from Server Components or Server Actions, read cookies via next/headers
and forward them to your GraphQL server.
// app/api/graphql/route.ts (example cookie passthrough)
import { cookies } from 'next/headers';
// ... inside Yoga/Apollo context creation:
const auth = cookies().get('session')?.value;
Caching and revalidation
With Apollo, choose between in-memory caching only on the client, or explicit RSC-level revalidation on the server. When you call client.query
in a Server Component, opt into next: { revalidate: X }
to cache per route. URQL uses a normalized document cache; its SSR exchange simply hydrates it on the client. Next.js 15’s shift to “uncached by default” for GET Route Handlers means your GraphQL endpoint won’t be accidentally memoized—add Cache-Control
and revalidation windows intentionally if you implement persisted queries or public caching.
Mutations: client vs. server
Client-first: Wrap UX flows with useMutation
(Apollo) or useMutation
(URQL) and update caches optimistically for snappy feel.
Server-first: For privileged writes, a Server Action can call the same GraphQL mutation using your server-scoped client, then revalidate segments. Server Actions integrate directly with Next’s caching, so the UI and data update in a single roundtrip.
// Client UI calling a Server Action
'use client';
import { useTransition } from 'react';
import { sendPing } from '../actions';
export function PingForm() {
const [pending, start] = useTransition();
return (
<form
action={(formData) => start(() => sendPing(formData.get('message') as string))}
>
<input name="message" />
<button disabled={pending}>{pending ? 'Sending…' : 'Send'}</button>
</form>
);
}
Subscriptions
For local development or simple needs, mount WebSocket subscriptions beside your Route Handler using a WS server (e.g., graphql-ws
) and have Apollo/URQL point to it with split links/exchanges. If you deploy to a serverless platform, prefer hosted WS (or SSE over HTTP/2) to avoid long-lived connection limits. (Implementation details vary per host; consult your platform’s WS guidance.)
Troubleshooting quick hits
- 404 on
/api/graphql
: Ensure you export both methods from your handler:export { yoga as GET, yoga as POST }
. - App Router + Apollo wiring: Use the Next.js integration package, not custom React wrappers; it exposes
ApolloNextAppProvider
and request-scoped clients. - URQL hydration gaps: Prefetch on the server,
extractData()
, thenrestoreData()
in your provider; do not pass a liveclient
instance across the RSC boundary.
When to pick Apollo vs. URQL
Pick Apollo if you want opinionated tooling, GraphOS integrations, and built-in streaming SSR with the official Next.js package. Pick URQL if you prefer a lean core with composable exchanges and explicit SSR hydration. Both integrate cleanly with the App Router; both let you run queries on the server and the client with one schema.