Skip to main content

Suspense-Enabled Data Libraries: Server Components & Frameworks

Most real-world Suspense integrations don't rely on manual promise throwing. Instead, you use Suspense-enabled libraries and frameworks that handle promise creation and caching automatically. These tools—Next.js Server Components, TanStack Query in Suspense mode, and Relay—natively coordinate with Suspense boundaries, eliminating boilerplate and preventing bugs.

This article surveys the ecosystem, explains how each integrates with Suspense, and shows practical setup patterns for modern apps in 2026.

Next.js Server Components: The Native Approach

Next.js App Router uses Server Components as the default, and they are Suspense-native by design. A Server Component can await data directly, and Next.js automatically throws a promise to the nearest Suspense boundary on the client:

// app/posts/page.js (Server Component)
import { Suspense } from 'react';

async function PostsList() {
// This await happens on the server; no client-side promise throwing
const posts = await fetch('https://api.example.com/posts');
const data = await posts.json();

return (
<ul>
{data.map(post => (
<li key={post.id}>{post.title}</li>
))}
</ul>
);
}

function PostsLoading() {
return <div>Loading posts...</div>;
}

export default function Page() {
return (
<Suspense fallback={<PostsLoading />}>
<PostsList />
</Suspense>
);
}

Here, PostsList is a Server Component that awaits the fetch. No use hook needed. Next.js streams the HTML, showing the fallback until the fetch completes on the server. This is the simplest Suspense integration because the fetch happens server-side, not in a promise.

For client-side data in Server Components, use the use hook:

// app/profile/page.js
'use client'; // Client Component

import { use, Suspense } from 'react';

function ProfileContent({ userPromise }) {
const user = use(userPromise); // Unwrap client-side promise
return <div>Name: {user.name}</div>;
}

// This is a Server Component that passes a promise to a Client Component
async function Page() {
const userPromise = fetch('https://api.example.com/user').then(r => r.json());

return (
<Suspense fallback={<div>Loading profile...</div>}>
<ProfileContent userPromise={userPromise} />
</Suspense>
);
}

TanStack Query with Suspense Mode

TanStack Query (formerly React Query) version 5+ supports Suspense natively via its experimental suspense option. This lets queries throw promises to Suspense instead of using useQuery with state:

import { useQuery } from '@tanstack/react-query';
import { Suspense } from 'react';

// Enable suspense in QueryClientProvider setup
const queryClient = new QueryClient({
defaultOptions: {
queries: {
suspense: true, // Global default
},
},
});

function UserCard({ userId }) {
// With suspense: true, throws promise to Suspense if data is not cached
const { data: user } = useQuery({
queryKey: ['user', userId],
queryFn: () => fetch(`/api/users/${userId}`).then(r => r.json()),
});

return <div>Name: {user.name}</div>;
}

export function UserPage({ userId }) {
return (
<Suspense fallback={<div>Loading user...</div>}>
<UserCard userId={userId} />
</Suspense>
);
}

TanStack Query manages caching, retries, and background updates. When you request a query with suspense: true, if the data is not in cache, useQuery throws the fetch promise. Once cached, it returns the data synchronously on every render. This combines Suspense with TanStack Query's powerful cache invalidation and stale-while-revalidate patterns.

Relay: GraphQL with Suspense

Relay is Meta's GraphQL client built for Suspense from the ground up. It uses fragments (component-level data declarations) and a compiler to coordinate data fetching:

// UserCard.jsx
import { useLazyLoadQuery, graphql } from 'react-relay';

const UserCardFragment = graphql`
fragment UserCard_user on User {
id
name
email
}
`;

function UserCard({ user }) {
return (
<div>
<h2>{user.name}</h2>
<p>{user.email}</p>
</div>
);
}

// In the parent (Page)
const UserPageQuery = graphql`
query UserPageQuery($userId: ID!) {
user(id: $userId) {
...UserCard_user
}
}
`;

function UserPage({ userId }) {
const data = useLazyLoadQuery(UserPageQuery, { userId });

return (
<div>
<UserCard user={data.user} />
</div>
);
}

export function App({ userId }) {
return (
<Suspense fallback={<div>Loading...</div>}>
<UserPage userId={userId} />
</Suspense>
);
}

Relay's useLazyLoadQuery hook works identically to the use hook: it throws the GraphQL request promise if data is not cached and returns the data synchronously once it arrives. The Relay compiler ensures type safety and optimizes queries at build time.

Comparing Suspense-Enabled Libraries

LibraryWhen to UseHow It WorksSetup Complexity
Next.js Server ComponentsFull-stack React; server fetchesAwait on server, stream HTMLLow; just use async in components
TanStack QueryClient-side fetching with cachinguseQuery with suspense: trueMedium; requires QueryClientProvider + config
RelayGraphQL APIs with type safetyuseLazyLoadQuery + fragmentsHigh; requires build-time compiler + schema
Manual fetch + useSmall apps or learningCreate promise, pass to componentLow; just raw fetch + use

Mixing Client and Server Suspense Boundaries

In modern Next.js apps, you often nest Client Component Suspense boundaries inside Server Components:

// app/dashboard/page.js (Server Component)
import { Suspense } from 'react';
import { StatsSidebar } from './stats-sidebar'; // Client Component

async function MainContent() {
const content = await fetch('https://api.example.com/content')
.then(r => r.json());
return <article>{content.body}</article>;
}

export default function Page() {
return (
<div style={{ display: 'grid', gridTemplateColumns: '1fr 300px' }}>
{/* Server-side Suspense */}
<Suspense fallback={<div>Loading content...</div>}>
<MainContent />
</Suspense>

{/* Client-side Suspense (e.g., TanStack Query) */}
<Suspense fallback={<div>Loading stats...</div>}>
<StatsSidebar />
</Suspense>
</div>
);
}

// components/stats-sidebar.jsx
'use client';

import { useQuery } from '@tanstack/react-query';

export function StatsSidebar() {
const { data: stats } = useQuery({
queryKey: ['stats'],
queryFn: () => fetch('/api/stats').then(r => r.json()),
suspense: true,
});

return (
<aside>
<h3>Stats</h3>
<p>Views: {stats.views}</p>
</aside>
);
}

The page shows a server-side skeleton for content and a client-side skeleton for stats simultaneously. Once each resolves, its content appears. The HTML is streamed progressively: first the server-rendered shells, then the filled content.

Error Handling with Suspense-Enabled Libraries

When using a library with Suspense, errors from the data fetch are thrown as exceptions. Wrap your Suspense boundaries in an Error Boundary:

import { QueryErrorResetBoundary } from '@tanstack/react-query';

<QueryErrorResetBoundary>
{({ reset }) => (
<ErrorBoundary onReset={reset}>
<Suspense fallback={<div>Loading...</div>}>
<MyComponent />
</Suspense>
</ErrorBoundary>
)}
</QueryErrorResetBoundary>

TanStack Query provides QueryErrorResetBoundary to reset the error state, allowing retries after an error occurs.

Choosing the Right Library for Your Project

Use Next.js Server Components if:

  • You control the backend and can await data there.
  • You want the simplest streaming HTML experience.
  • You're building a new Next.js 14+ app.

Use TanStack Query if:

  • You have a public API and need client-side fetching.
  • You need powerful caching, stale-while-revalidate, and background invalidation.
  • You're working in a monorepo or microservices architecture.

Use Relay if:

  • You have a GraphQL API.
  • You need strong type safety and build-time optimization.
  • You're in a large team with complex data dependencies.

Use manual fetch + use if:

  • You're learning or building a small prototype.
  • You need fine-grained control over promises.
  • You're integrating Suspense into an existing app incrementally.

Key Takeaways

  • Server Components enable server-side async: Next.js Server Components await data and stream results; no client-side promise throwing needed.
  • TanStack Query Suspense mode: Set suspense: true to throw fetch promises to Suspense; the library handles caching.
  • Relay integrates GraphQL with Suspense: useLazyLoadQuery hooks throw GraphQL requests and return data synchronously once cached.
  • Mix and match: Nest client-side (TanStack Query) and server-side (Server Components) Suspense boundaries in the same app.
  • Error Boundary always required: All Suspense-enabled libraries throw errors to Error Boundary, not Suspense.

Frequently Asked Questions

Can I use multiple Suspense libraries in one app?

Yes. For example, use Server Components for page-level data and TanStack Query for sidebar widgets. Each boundary handles its own fallback independently.

Do I need to migrate to Server Components to use Suspense?

No. Suspense works in Client Components too (via the use hook or TanStack Query). Server Components simplify things by fetching on the server, but they're not required.

How does TanStack Query cache work with Suspense?

When you request a query, TanStack Query checks the cache. If data is cached (and fresh), useQuery returns synchronously without throwing. If missing or stale, it throws the fetch promise to Suspense, then updates the cache once data arrives.

Can Relay work with REST APIs?

Relay is GraphQL-specific. For REST APIs, use TanStack Query, which has no dependency on GraphQL and works with any HTTP endpoint.

What if my library doesn't support Suspense?

Wrap the library's promise manually in a component that calls use(). Create a small wrapper component to bridge non-Suspense libraries to your Suspense boundaries.

Further Reading