How often have you encountered a web application that felt sluggish, prompting a frustrating wait for content to load?
Enter Suspense, the React 16+ feature that lets you render a fallback declaratively while a component is waiting for some asynchronous operation. Now, Next.js 13+ offers a new way to add Suspense to application using the app router, and help developers handle loading states better than before.
In this blog post, we delve into a practical case study: a Next.js 14 page designed to fetch Pokémon data from the PokeAPI. We'll compare the performance of two versions of this page — one leveraging Suspense and the other not.
Using Suspense, you can delay displaying parts of your website until all necessary data is received. During this wait, a fallback UI is shown, preventing users from encountering incomplete content.
This method not only speeds up the initial page load but also significantly improves the overall user experience by ensuring seamless and uninterrupted interactions with your application.
You can pre-render loading indicators such as skeletons and spinners, or a small but meaningful part of future screens such as a cover photo, title, etc; reassuring users that the app is actively processing their request.
In this scenario, we fetched 150 pokemons using the pokeapi.co. To simulate the delay of data fetching from the server, we've added a 2-second timeout, in the server action.
"use server";
import { PokemonList, pokemonListSchema } from "./types";
export async function getPokemonsList(): Promise<PokemonList> {
await new Promise((resolve) => setTimeout(resolve, 2000));
const response = await fetch(
"[https://pokeapi.co/api/v2/pokemon-form?limit=150](http://pokeapi.co/)"
);
const data = await response.json();
const parsedData = pokemonListSchema.parse(data);
return parsedData;
}
and then mapping over the result in the home page.
<div className="grid grid-cols-3 max-w-6xl gap-4 w-full mx-auto">
{pokemons.results.map((pokemon, index) => (
<Link
href={`/pokemon/${pokemon.name}`}
className="flex flex-col p-6 hover:bg-gray-50 dark:hover:bg-gray-900 items-center border border-gray-200 hover:border-gray-300 dark:border-gray-800 hover:dark:border-gray-700 rounded-md"
key={index}
>
<Image
src={`/assets/${pokemon.name}.jpg`}
height={240}
width={240}
alt={pokemon.name}
/>
<p>
{pokemon.name.charAt(0).toUpperCase() + pokemon.name.slice(1)}
</p>
</Link>
))}
</div>
The page waits until all the data has been fetched, delaying the display of any content. Thus, the user must wait even for the initial character to render.
For this version, We created a new route called 'suspense' and added a file called loading.tsx
, designed to represent the skeleton of the entire page. This will create a simple loading state.
import { PokemonSkeleton } from "./page";
export default function Loading() {
return (
<div className="flex w-full min-h-screen flex-col items-center justify-start p-24 pt-12">
<div className="bg-blue-700 uppercase text-white p-1 font-medium tracking-wide rounded-full border px-4 mb-3.5">
With Suspense
</div>
<div className="w-full mx-auto mb-20 flex justify-center">
<h1 className="text-3xl font-medium w-fit">Pokedex</h1>
</div>
<div className="grid grid-cols-3 max-w-6xl gap-4 w-full mx-auto">
{Array.from({ length: 120 }).map((_, index) => (
<PokemonSkeleton key={index} />
))}
</div>
</div>
);
}
In addition to loading.tsx
We manually created Suspense Boundaries for your individual Pokemon Cards. The App Router supports streaming with Suspense.
Streaming allows you to break down the page's HTML into smaller chunks and progressively send those chunks from the server to the client.
This enables faster display of parts of the page without waiting for all data to load. Streaming aligns well with React's component model, as each component can be treated as a chunk, improving core vital stats.
export async function PokemonList() {
const pokemons = await getPokemonsList();
return (
<div className="grid grid-cols-3 max-w-6xl gap-4 w-full mx-auto">
{pokemons.results.map((pokemon, index) => (
<Suspense key={index} fallback={<PokemonSkeleton />}>
<Link
href={`/pokemon/${pokemon.name}`}
className="flex flex-col p-6 hover:bg-gray-50 dark:hover:bg-gray-900 items-center border border-gray-200 hover:border-gray-300 dark:border-gray-800 hover:dark:border-gray-700 rounded-md"
key={index}
>
<Image
src={`/assets/${pokemon.name}.jpg`}
height={240}
width={240}
alt={pokemon.name}
/>
<p>
{pokemon.name.charAt(0).toUpperCase() + pokemon.name.slice(1)}
</p>
</Link>
</Suspense>
))}
</div>
);
}
Custom loading UI with Suspense :
Let's now compare the page loading times for both cases.
It takes more than 2 seconds here.
But here, the page load is almost instant. The fallback UI appears until the data fetching is complete.
Kudos to the Nextjs team for their hard work.
By incorporating Next.js Suspense with asynchronous components, you can significantly enhance the performance and responsiveness of your Next.js applications.
Happy coding!