← Back to blog

Apollo Client + Next.js 16 App Router Guide

March 15, 2026·10 min read
ReactNext.jsGraphQL

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:

ContextWhere it runsHook / APIUpdates in browser?
React Server Components (RSC)Server onlygetClient() / query()No
Client ComponentsBrowser (+ SSR pass)useSuspenseQuery, useQueryYes

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/useSuspenseQuery in 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 HttpLink relative 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:

Related Posts