javascript
54 lines · 8 steps
Streaming a product list with Suspense in Next.js
An async Server Component fetches data while Suspense streams a skeleton placeholder until it's ready.
Explained by
highlit
1import { Suspense } from 'react';
2import { getProducts } from '@/lib/api/products';
3import { formatPrice } from '@/lib/format';
4
5export default function ProductsPage({ searchParams }) {
6 const category = searchParams?.category ?? 'all';
7
8 return (
9 <section className="mx-auto max-w-6xl px-4 py-8">
10 <h1 className="mb-6 text-2xl font-semibold">Products</h1>
11 <Suspense key={category} fallback={<ProductGridSkeleton />}>
12 <ProductGrid category={category} />
13 </Suspense>
14 </section>
15 );
16}
17
18async function ProductGrid({ category }) {
19 const products = await getProducts({ category });
20
21 if (products.length === 0) {
22 return <p className="text-sm text-gray-500">No products found.</p>;
23 }
24
25 return (
26 <ul className="grid grid-cols-2 gap-4 sm:grid-cols-3 lg:grid-cols-4">
27 {products.map((product) => (
28 <li key={product.id} className="rounded-lg border p-4">
29 <img
30 src={product.imageUrl}
31 alt={product.name}
32 className="aspect-square w-full rounded-md object-cover"
33 />
34 <h2 className="mt-3 truncate font-medium">{product.name}</h2>
35 <p className="text-sm text-gray-600">{formatPrice(product.price)}</p>
36 </li>
37 ))}
38 </ul>
39 );
40}
41
42function ProductGridSkeleton() {
43 return (
44 <ul className="grid grid-cols-2 gap-4 sm:grid-cols-3 lg:grid-cols-4">
45 {Array.from({ length: 8 }).map((_, i) => (
46 <li key={i} className="rounded-lg border p-4">
47 <div className="aspect-square w-full animate-pulse rounded-md bg-gray-200" />
48 <div className="mt-3 h-4 w-3/4 animate-pulse rounded bg-gray-200" />
49 <div className="mt-2 h-4 w-1/3 animate-pulse rounded bg-gray-200" />
50 </li>
51 ))}
52 </ul>
53 );
54}
01 / 01
STEP 01
‹ swipe to step through ›
Walkthrough
Space play
←→ step
click any line
Three takeaways
- 1Async Server Components let you await data directly inside the component instead of using effects or client-side fetching.
- 2Wrapping an async child in Suspense streams a fallback immediately and swaps in the real UI once the data resolves.
- 3Keying Suspense on a changing prop forces a fresh fallback whenever that input changes, keeping loading states accurate.
Related explainers
javascript
function evaluate(expression) { const tokens = tokenize(expression); let pos = 0;
Building a recursive descent calculator
parsing
recursion
operator-precedence
Intermediate
8 steps
javascript
import { useState, useEffect, useCallback, useRef } from 'react'; const cache = new Map(); const inflight = new Map();
Building a stale-while-revalidate hook in React
caching
request-deduplication
custom-hooks
Advanced
10 steps
python
import random from typing import Iterator, List
How reservoir sampling picks k items
reservoir-sampling
streaming
randomness
Intermediate
5 steps
javascript
import { useEffect, useRef, useState } from 'react'; export function useDelayedFlag(active, delay = 300) { const [visible, setVisible] = useState(false);
Delaying a loading spinner with a React hook
custom-hooks
debouncing
cleanup
Intermediate
8 steps
javascript
const SWIPE_THRESHOLD = 80; const MAX_TRANSLATE = 120; export function attachSwipeToDismiss(element, onDismiss) {
Building a swipe-to-dismiss gesture in JS
touch-events
gesture-detection
dom-manipulation
Intermediate
10 steps
javascript
const { pool } = require('./db'); function withTransaction() { return async (req, res, next) => {
A per-request transaction middleware in Express
middleware
database-transactions
connection-pooling
Advanced
7 steps
Share this explainer
Here's the card — post it anywhere.
Made with highlit — turn any snippet into a walkthrough like this in about a minute.
Explain your code
Embed this explainer
Drop the interactive walkthrough into a blog or docs. Views never cost a credit.
<iframe src="https://highlit.co/explainers/streaming-a-product-list-with-suspense-in-next-js-explained-javascript-9467/embed?autoplay=1" width="100%" height="520" loading="lazy" style="border:0"></iframe>
Autoplay is on by default — add ?autoplay=0 to start paused.