Apollo Client + Next.js 16 App Router Guide
React 19.2 shipped stable Server Components and a refined Suspense boundary batching model. Next.js 16 introduced the "use cache" directive and a revamped Turbopack-backed dev server. If you want to use Apollo Client in this environment, the setup is meaningfully different from the classic Pages Router pattern you'll find in most older tutorials. This guide walks through the correct, production-ready approach.
The Two Contexts: RSC vs. Client Components
Before touching any code, the single most important concept to internalize is that Apollo Client operates in two completely separate contexts in an App Router project:
| Context | Where it runs | Hook / API | Updates in browser? |
|---|---|---|---|
| React Server Components (RSC) | Server only | getClient() / query() | No |
| Client Components | Browser (+ SSR pass) | useSuspenseQuery, useQuery | Yes |
Do not overlap queries between the two. RSC-fetched data is static after the initial render it won't reactively update in the browser. Client-side Apollo queries will. Mixing them for the same data causes cache inconsistencies.
Installation
npm install @apollo/client @apollo/client-integration-nextjs graphql@apollo/client-integration-nextjs is the stable release of what was previously @apollo/experimental-nextjs-app-support. It ships two distinct entry points one for server usage and one for client usage so tree-shaking works correctly.
Step 1: Server-Side Apollo Client (RSC)
Create a dedicated file for your server-side client. This uses registerApolloClient, which ensures a per-request singleton during SSR and a stable singleton during RSC rendering:
// lib/apollo-server.ts
import { HttpLink } from "@apollo/client";
import {
ApolloClient,
InMemoryCache,
registerApolloClient,
} from "@apollo/client-integration-nextjs";
export const { getClient, query, PreloadQuery } = registerApolloClient(() => {
return new ApolloClient({
cache: new InMemoryCache(),
link: new HttpLink({
// Must be an absolute URL for SSR
uri: process.env.NEXT_PUBLIC_GRAPHQL_URL,
}),
});
});Use getClient() or the exported query() helper directly inside any Server Component:
// app/users/page.tsx (Server Component no "use client")
import { query } from "@/lib/apollo-server";
import { gql } from "@apollo/client";
const GET_USERS = gql`
query GetUsers {
users {
id
name
email
role
}
}
`;
export default async function UsersPage() {
const { data } = await query({ query: GET_USERS });
return (
<ul>
{data.users.map((user) => (
<li key={user.id}>
<strong>{user.name}</strong> {user.email}
</li>
))}
</ul>
);
}This data is fetched at request time on the server, HTML-streamed to the client, and never re-fetched by Apollo in the browser. If you need this data to stay fresh on the client, move it to a Client Component instead.
Caching RSC Responses with Next.js 16's "use cache"
Next.js 16 introduced the "use cache" directive, giving you explicit, fine-grained control over server-side caching without fetch cache hacks. You can apply it at the function or component level:
// app/users/page.tsx
import { query } from "@/lib/apollo-server";
import { gql } from "@apollo/client";
import { cacheLife } from "next/cache";
const GET_USERS = gql`
query GetUsers {
users { id name email }
}
`;
async function fetchUsers() {
"use cache";
cacheLife("minutes"); // cache for a few minutes
return query({ query: GET_USERS });
}
export default async function UsersPage() {
const { data } = await fetchUsers();
// ...
}Step 2: Client-Side Apollo Provider
For interactive Client Components you need a provider. Create an ApolloWrapper that lives at the client boundary:
// app/ApolloWrapper.tsx
"use client";
import { HttpLink } from "@apollo/client";
import {
ApolloClient,
ApolloNextAppProvider,
InMemoryCache,
} from "@apollo/client-integration-nextjs";
function makeClient() {
const httpLink = new HttpLink({
uri: process.env.NEXT_PUBLIC_GRAPHQL_URL,
});
return new ApolloClient({
cache: new InMemoryCache(),
link: httpLink,
});
}
export function ApolloWrapper({ children }: React.PropsWithChildren) {
return (
<ApolloNextAppProvider makeClient={makeClient}>
{children}
</ApolloNextAppProvider>
);
}Wrap your root layout with it:
// app/layout.tsx
import { ApolloWrapper } from "./ApolloWrapper";
export default function RootLayout({ children }: React.PropsWithChildren) {
return (
<html lang="en">
<body>
<ApolloWrapper>{children}</ApolloWrapper>
</body>
</html>
);
}Note that ApolloWrapper itself is a Client Component, but RootLayout remains a Server Component this is the correct way to introduce client context at the root.
Step 3: Fetching Data in Client Components
useSuspenseQuery Preferred for Streaming SSR
In React 19.2, useSuspenseQuery is the right choice for most data-fetching in Client Components. It integrates with React's Suspense model, enabling streaming SSR where the server sends a shell immediately and streams the resolved data as it becomes available.
// app/users/UsersList.tsx
"use client";
import { gql } from "@apollo/client";
import { useSuspenseQuery } from "@apollo/client";
const GET_USERS = gql`
query GetUsers {
users {
id
name
email
}
}
`;
export function UsersList() {
// Suspends until data is ready no loading/error state needed here
const { data } = useSuspenseQuery(GET_USERS);
return (
<ul>
{data.users.map((user) => (
<li key={user.id}>{user.name} {user.email}</li>
))}
</ul>
);
}Wrap it with Suspense in the parent:
// app/users/page.tsx
import { Suspense } from "react";
import { UsersList } from "./UsersList";
export default function UsersPage() {
return (
<Suspense fallback={<p>Loading users...</p>}>
<UsersList />
</Suspense>
);
}React 19.2 improves Suspense boundary batching multiple sibling boundaries that resolve around the same time are batched together, reducing layout shifts from staggered reveals.
useQuery For Non-Suspense Cases
Use useQuery when you need manual control over loading/error states (e.g., inside a component that shouldn't suspend its siblings):
"use client";
import { gql, useQuery } from "@apollo/client";
const GET_USER = gql`
query GetUser($id: ID!) {
user(id: $id) {
id
name
email
}
}
`;
export function UserProfile({ userId }: { userId: string }) {
const { data, loading, error } = useQuery(GET_USER, {
variables: { id: userId },
});
if (loading) return <p>Loading...</p>;
if (error) return <p>Error: {error.message}</p>;
return <p>{data.user.name}</p>;
}Step 4: Mutations with Optimistic UI
Apollo's useMutation hook accepts an optimisticResponse option that immediately writes a temporary result to the cache before the server responds. The UI updates instantly; if the mutation fails, Apollo rolls back automatically.
"use client";
import { gql, useMutation } from "@apollo/client";
const UPDATE_USER_ROLE = gql`
mutation UpdateUserRole($id: ID!, $role: String!) {
updateUserRole(id: $id, role: $role) {
id
role
}
}
`;
export function RoleSelector({
user,
}: {
user: { id: string; name: string; role: string };
}) {
const [updateRole] = useMutation(UPDATE_USER_ROLE);
const handleChange = (newRole: string) => {
updateRole({
variables: { id: user.id, role: newRole },
optimisticResponse: {
updateUserRole: {
__typename: "User",
id: user.id,
role: newRole,
},
},
});
};
return (
<select value={user.role} onChange={(e) => handleChange(e.target.value)}>
<option value="admin">Admin</option>
<option value="editor">Editor</option>
<option value="viewer">Viewer</option>
</select>
);
}The __typename field is required it's how Apollo's normalized cache identifies and merges the optimistic write with the real response.
Step 5: Seeding the Client Cache from RSC (PreloadQuery)
A common pattern is to fetch data in an RSC, then hand it off to a Client Component so it's available instantly without a client-side waterfall. PreloadQuery does this:
// app/users/page.tsx (Server Component)
import { PreloadQuery } from "@/lib/apollo-server";
import { Suspense } from "react";
import { UsersList } from "./UsersList";
import { GET_USERS } from "./queries";
export default function UsersPage() {
return (
<PreloadQuery query={GET_USERS}>
<Suspense fallback={<p>Loading...</p>}>
<UsersList />
</Suspense>
</PreloadQuery>
);
}UsersList uses useSuspenseQuery with the same GET_USERS query. When it renders, the data is already in the cache from the server pass no second network request.
Architecture Summary
app/layout.tsx (Server Component)
└── ApolloWrapper (Client boundary "use client")
└── app/page.tsx (can be Server or Client)
├── RSC: uses getClient() / query() / PreloadQuery server fetch only
└── Client Component: uses useSuspenseQuery / useQuery / useMutation
Key rules:
- Never use
useQuery/useSuspenseQueryin a Server Component hooks don't run on the server. - Never use
getClient()in a Client Component it's a server-only export. - Avoid querying the same field in both RSC and a Client Component the RSC version won't update reactively, creating stale UI.
- Always use absolute URLs in
HttpLinkrelative URLs fail during SSR.
Conclusion
The Apollo + Next.js 16 integration requires understanding the hard boundary between RSC and client rendering. Use registerApolloClient with query() for server-side static fetches, "use cache" to control their freshness, ApolloNextAppProvider to provide the client instance, useSuspenseQuery for streaming-compatible client fetches, and optimisticResponse in mutations for instant UI feedback. Each piece has a specific role mixing them up is the most common source of bugs in this stack.
For more of my work, explore my projects or services.
Sources: