apollo-client-nextjs icon indicating copy to clipboard operation
apollo-client-nextjs copied to clipboard

PreloadQuery + useSuspenseQuery shows Suspense fallback in SSR HTML despite documentation stating it shouldn't

Open longfellowone opened this issue 5 months ago • 1 comments

Environment:

  • @apollo/client: 4.0.9
  • @apollo/client-integration-nextjs: 0.14.1
  • next: 16.0.1
  • react: 19.2.0

Description:

When using the recommended PreloadQuery + useSuspenseQuery pattern for Next.js App Router, the Suspense fallback always appears in the initial server-rendered HTML, causing a visible flash during page load.

According to the official documentation:

"During initial render, the fallback should NOT appear because data preloading completes server-side before hydration."

And:

"The Suspense boundary here is optional and only for demonstration purposes."

However, in practice, the fallback is consistently rendered in the SSR HTML.

Expected Behavior:

The Suspense fallback should not appear in the initial HTML response. The preloaded data should be available immediately during hydration, preventing the Suspense boundary from activating.

Actual Behavior:

The Suspense fallback appears in the SSR HTML:

<!--$?--><template id="B:0"></template><div>Loading products...</div><!--/$-->

This causes a visible flash as the page loads, then hydrates with the actual data.


Code Example

Server Component (app/page.tsx):

import { PreloadQuery } from "@/lib/apollo-client";
import ProductsList from "@/components/products-list";
import { GET_PRODUCTS } from "@/lib/queries/products";
import { Suspense } from "react";

export default function Home() {
  return (
    <PreloadQuery query={GET_PRODUCTS}>
      <Suspense fallback={<div>Loading products...</div>}>
        <ProductsList />
      </Suspense>
    </PreloadQuery>
  );
}

Client Component (components/products-list.tsx):

'use client';

import { useSuspenseQuery } from '@apollo/client/react';
import { GET_PRODUCTS, type Product } from '@/lib/queries/products';

export default function ProductsList() {
  const { data } = useSuspenseQuery<{ products: Product[] }>(GET_PRODUCTS);

  return (
    <div>
      <h1>Electrical Supply Products</h1>
      <ul>
        {data.products.map((product) => (
          <li key={product.id}>
            {product.name} - ${product.price.toFixed(2)}
          </li>
        ))}
      </ul>
    </div>
  );
}

Apollo Client Setup (lib/apollo-client.ts):

import { HttpLink } from "@apollo/client";
import {
  registerApolloClient,
  ApolloClient,
  InMemoryCache,
} from "@apollo/client-integration-nextjs";

export const { getClient, query, PreloadQuery } = registerApolloClient(() => {
  return new ApolloClient({
    cache: new InMemoryCache(),
    link: new HttpLink({
      uri: "http://localhost:8080/query",
    }),
  });
});

Client Wrapper (lib/apollo-wrapper.tsx):

"use client";

import { HttpLink } from "@apollo/client";
import {
  ApolloNextAppProvider,
  ApolloClient,
  InMemoryCache,
} from "@apollo/client-integration-nextjs";

function makeClient() {
  const httpLink = new HttpLink({
    uri: "http://localhost:8080/query",
  });

  return new ApolloClient({
    cache: new InMemoryCache(),
    link: httpLink,
  });
}

export function ApolloWrapper({ children }: { children: React.ReactNode }) {
  return (
    <ApolloNextAppProvider makeClient={makeClient}>
      {children}
    </ApolloNextAppProvider>
  );
}

Attempted Workarounds

  1. Importing the streaming module - Adding import "@apollo/client-react-streaming"; to preload the module (as suggested in #506) did not resolve the issue.

  2. Props-based approach works - Using direct await getClient().query() in RSC and passing data as props eliminates the flash completely, but this bypasses the PreloadQuery pattern entirely.

longfellowone avatar Nov 10 '25 20:11 longfellowone

This will be a dissatisfying answer :/

It is possible that React directly streams the final data. But in the end, Next.js and React decide.

  • If you're identified as a search engine, Next.js will switch completely and just "hang" longer instead of displaying a suspense fallback.
  • If you are prerendering static content with Next.js, you will probably also not see a suspense fallback.
  • If you are dynamically server-side rendering, it's probably up to React to decide - and that will likely depend on the speed of your GraphQL server's response. It probably won't show a suspense fallback for a few milliseconds, but might do so for longer waiting times, as React always tries to get something on the screen fast, and even a fallback is better than nothing at all from React's perspective.

Either way: it's pretty much out of our hand. We do everything to enable situations where a suspense fallback isn't streamed, but we don't have the ability to finally enforce it.

phryneas avatar Nov 11 '25 08:11 phryneas