Apollo Client SSR Cache Hydration in the Next.js App Router

Within Apollo Client GraphQL Caching, this guide covers the server-to-browser handoff: fetching CMS content during server rendering in the Next.js App Router and hydrating it into the browser’s Apollo cache, so the first client render reads from memory instead of repeating every query.

The App Router changes the rules that older getServerSideProps recipes relied on. React Server Components run on the server only and cannot use hooks, client components render once on the server during SSR and again in the browser, and streaming means parts of the page arrive after the initial HTML. A single global client with cache.extract() serialized into a script tag no longer covers all three cases.

Where Apollo clients live in an App Router pageThree layers of the page, each with its own Apollo client instance and a different job: RSC fetches, SSR of client components, and the browser cache.React Server Componentsserver onlygetClient() per requestno hooksno cache in browserClient components, SSR passserver, per requestuseSuspenseQuerystreams resultsClient components, browserone per tabsame queriesrestored from stream
Three client instances with three lifetimes; the handoff between the bottom two is what hydration means.

The Problem

A Contentful-backed product page renders its header and body with useQuery in client components. In the browser’s Network panel every query from the server render runs a second time on load: the server fetched the data, rendered HTML with it, then threw the cache away. The page flickers from content to a loading state to content, CMS API usage doubles, and Largest Contentful Paint suffers because the hero text waits for the second fetch.

Two mistakes produce this. Creating the Apollo client at module scope on the server shares one cache across every request, which leaks data between users and between locales. Creating it per request without transferring its contents to the browser discards the work. The integration package for Next.js solves both. It is published as @apollo/client-integration-nextjs, formerly @apollo/experimental-nextjs-app-support.

How the Handoff Works

Server Components use a request-scoped client obtained from registerApolloClient. Its results feed server-rendered markup directly and never need to reach the browser cache, unless a client component below will query the same data.

Client components are wrapped in ApolloNextAppProvider, which creates one client per request during SSR and one per tab in the browser. When a client component calls useSuspenseQuery during SSR, the package streams each completed result into the HTML as it resolves, and the browser client writes those results into its cache before hydration reaches that component. By the time the component hydrates, its query is a cache hit.

PreloadQuery bridges the two worlds: a Server Component starts a query early, and the result is streamed to the client cache for a client component to read.

Streaming a preloaded query into the browser cacheA Server Component preloads a query, the SSR client streams the result into the HTML, the browser cache restores it, and the client component hydrates without a network request.Server ComponentSSR Apollo clientContentfulBrowser cachePreloadQuery(PRODUCT)POST querydatastream result in HTMLwrite to InMemoryCacheuseSuspenseQuery: cache hit
The result crosses the network once, from the CMS to the server; the browser receives it inside the HTML stream.

Implementation

Three files carry the whole setup: a shared cache factory, the RSC client and the client provider. Sharing the typePolicies between them matters, because the streamed results are written with the browser cache’s policies. If the server and browser disagree on keyFields, the restored data lands under keys the components never read.

TSX
// lib/apollo/cache.ts: one source of truth for cache policies
import { InMemoryCache } from "@apollo/client-integration-nextjs";
import { cursorConnection } from "./cursor-connection";

export function makeCache(): InMemoryCache {
  return new InMemoryCache({
    typePolicies: {
      Product: { keyFields: ["sys", ["id", "locale"]] },
      Query: { fields: { productCollection: cursorConnection(["where", "locale", "preview"]) } },
    },
  });
}

// lib/apollo/rsc.ts: request-scoped client for Server Components
import { HttpLink } from "@apollo/client";
import { ApolloClient, registerApolloClient } from "@apollo/client-integration-nextjs";
import { makeCache } from "./cache";

export const { getClient, query, PreloadQuery } = registerApolloClient(() => {
  return new ApolloClient({
    cache: makeCache(),
    link: new HttpLink({
      uri: process.env.CMS_GRAPHQL_URL,
      headers: { Authorization: `Bearer ${process.env.CMS_DELIVERY_TOKEN ?? ""}` },
      // Let Next.js cache the fetch and tag it for on-demand revalidation.
      fetchOptions: { next: { revalidate: 300, tags: ["cms"] } },
    }),
  });
});

// app/apollo-provider.tsx: client components, SSR pass and browser
"use client";
import { HttpLink } from "@apollo/client";
import { ApolloClient, ApolloNextAppProvider } from "@apollo/client-integration-nextjs";
import type { ReactNode } from "react";
import { makeCache } from "@/lib/apollo/cache";

function makeClient(): ApolloClient {
  return new ApolloClient({
    cache: makeCache(),
    link: new HttpLink({
      uri: process.env.NEXT_PUBLIC_CMS_GRAPHQL_URL,
      headers: { Authorization: `Bearer ${process.env.NEXT_PUBLIC_CMS_DELIVERY_TOKEN ?? ""}` },
    }),
  });
}

export function ApolloProvider({ children }: { children: ReactNode }): JSX.Element {
  return <ApolloNextAppProvider makeClient={makeClient}>{children}</ApolloNextAppProvider>;
}

// app/[locale]/products/[slug]/page.tsx: preload in RSC, read in a client component
import { PreloadQuery } from "@/lib/apollo/rsc";
import { PRODUCT_QUERY } from "@/lib/queries";
import { ProductView } from "./product-view";
import { Suspense } from "react";

export default async function Page({ params }: { params: Promise<{ locale: string; slug: string }> }): Promise<JSX.Element> {
  const { locale, slug } = await params;
  return (
    <PreloadQuery query={PRODUCT_QUERY} variables={{ slug, locale }}>
      <Suspense fallback={<p>Loading product…</p>}>
        <ProductView slug={slug} locale={locale} />
      </Suspense>
    </PreloadQuery>
  );
}

Inside ProductView, a "use client" component, useSuspenseQuery(PRODUCT_QUERY, { variables: { slug, locale } }) resolves from the streamed result. The variable values must match the preloaded ones exactly (key order does not matter), or the lookup misses and the browser fetches again.

Handling Draft Mode

Preview is where request-scoped clients prove their worth. When an editor opens a preview link, the draft-mode route sets the __prerender_bypass cookie, and every server render in that session must use the preview endpoint, a server-held preview token and no data cache. Because the registerApolloClient callback runs once per request, it can read draftMode() and build the right link:

TypeScript
// lib/apollo/rsc.ts (preview-aware variant)
import { HttpLink } from "@apollo/client";
import { ApolloClient, registerApolloClient } from "@apollo/client-integration-nextjs";
import { draftMode } from "next/headers";
import { makeCache } from "./cache";

export const { getClient, PreloadQuery } = registerApolloClient(async () => {
  const { isEnabled: preview } = await draftMode();
  return new ApolloClient({
    cache: makeCache(),
    link: new HttpLink({
      uri: preview ? process.env.CMS_PREVIEW_GRAPHQL_URL : process.env.CMS_GRAPHQL_URL,
      headers: {
        Authorization: `Bearer ${(preview ? process.env.CMS_PREVIEW_TOKEN : process.env.CMS_DELIVERY_TOKEN) ?? ""}`,
      },
      // Drafts must never be written to the shared fetch cache.
      fetchOptions: preview ? { cache: "no-store" } : { next: { revalidate: 300, tags: ["cms"] } },
    }),
  });
});

The browser half needs the same decision. Preloaded draft results stream into the browser cache like any other result, which is correct: the editor should see drafts. But the client components must also fetch drafts on their own, for example after a fetchMore. Pass a preview flag from the layout into the provider, so makeClient points at a server-side proxy route that attaches the preview token. That way the token never reaches the browser.

Measuring the Handoff

The Network panel is the quickest check. Load a page with a cold browser cache and filter by the GraphQL endpoint: with hydration working, you should see zero GraphQL requests until the reader interacts. In the Elements panel, the streamed results appear as inline script tags injected near the components that consumed them. Their total size is the hydration payload. Keep it under a few tens of kilobytes per page; if it grows past that, a query is selecting fields the first render does not show. In automated tests, a Playwright check that counts requests to the CMS host during initial load catches regressions when a teammate swaps useSuspenseQuery for useQuery.

Configuration Reference

Setting Where Notes
registerApolloClient RSC module Returns getClient, query and PreloadQuery, all scoped to the current request.
ApolloNextAppProvider root layout, client Creates one client per SSR request and one per browser tab.
fetchOptions.next.tags RSC HttpLink Lets revalidateTag("cms") from a webhook invalidate the server fetch cache.
NEXT_PUBLIC_* variables browser client Only published-content tokens; the RSC client can use server-only tokens.
Shared makeCache() both Identical typePolicies on both sides, required for streamed results to land correctly.

Gotchas & Edge Cases

  • Module-scope clients leak across requests. A new ApolloClient() at the top of a server module is shared by every visitor, including their locale and preview state. Always create clients inside registerApolloClient or makeClient.
  • useQuery does not stream. Only suspense-enabled hooks (useSuspenseQuery, useReadQuery) participate in streaming SSR. Plain useQuery renders a loading state on the server and fetches again in the browser.
  • Preview mode needs a different link. When draftMode() is enabled, the RSC client must use the preview endpoint and token and skip next.revalidate. Build the link conditionally inside the registerApolloClient callback, which runs per request.
  • Variable mismatch between preload and read. { slug, locale } versus { locale, slug: slug.toLowerCase() } are different cache lookups. Normalize variables in one helper and use it on both sides.
  • Large payloads inflate HTML. Every streamed result is embedded in the document. Keep hydrated queries to what the first viewport needs and let below-the-fold components fetch on the client.
CMS requests per page view by hydration setupNumber of GraphQL requests that reach the CMS for one product page view with four queries under three setups.Per-request client, no hydration8 requestsuseQuery with ssr: false4 requestsPreloadQuery + useSuspenseQuery4 requestsThe last two send the same count; only the hydrated setup also renders content in the HTML.
Counted for a product page with four queries; hydration removes the duplicate browser round trips entirely.

Frequently Asked Questions

Do I still need Apollo in Server Components if I only render there?

No. If a page never uses client components that query the CMS, calling fetch against the GraphQL endpoint in a Server Component is simpler and benefits directly from the Next.js data cache. Apollo pays off when client components need the same normalized data for interactivity.

How does on-demand revalidation interact with the hydrated cache?

revalidateTag clears the server’s fetch cache, so the next server render gets fresh data and streams it to new visitors. Tabs that are already open keep their browser cache until something evicts it; the eviction guide covers that half.

Does this work with the Pages Router?

The Pages Router uses a different mechanism: fetch in getStaticProps or getServerSideProps, return cache.extract() as a prop, and call cache.restore() when the browser client is created. The integration package targets the App Router only. Mixed apps need both setups, one per router, sharing the same makeCache() so policies stay identical.

Can the RSC client and the browser client share one cache object?

No. They run in different environments. The RSC cache lives for one request on the server and is never serialized, and the browser cache is rebuilt from streamed results. What they share is configuration, through a common makeCache() factory.