javascript
52 lines · 8 steps
Streaming search results in a Next.js Server Component
A server-rendered search page that streams results independently with Suspense while keeping the input instantly interactive.
Explained by
highlit
1import { Suspense } from 'react';
2import { searchProducts } from '@/lib/products';
3import SearchInput from './search-input';
4
5export const dynamic = 'force-dynamic';
6
7export default async function ProductSearchPage({ searchParams }) {
8 const { q = '' } = await searchParams;
9
10 return (
11 <section className="mx-auto max-w-2xl p-6">
12 <h1 className="mb-4 text-xl font-semibold">Search products</h1>
13 <SearchInput defaultValue={q} />
14 <Suspense key={q} fallback={<ResultsSkeleton />}>
15 <SearchResults query={q} />
16 </Suspense>
17 </section>
18 );
19}
20
21async function SearchResults({ query }) {
22 const term = query.trim();
23 if (!term) {
24 return <p className="mt-6 text-sm text-gray-500">Start typing to search.</p>;
25 }
26
27 const products = await searchProducts(term);
28 if (products.length === 0) {
29 return <p className="mt-6 text-sm text-gray-500">No matches for “{term}”.</p>;
30 }
31
32 return (
33 <ul className="mt-6 divide-y">
34 {products.map((product) => (
35 <li key={product.id} className="flex justify-between py-3">
36 <span>{product.name}</span>
37 <span className="text-gray-500">${product.price.toFixed(2)}</span>
38 </li>
39 ))}
40 </ul>
41 );
42}
43
44function ResultsSkeleton() {
45 return (
46 <ul className="mt-6 animate-pulse space-y-3">
47 {Array.from({ length: 5 }).map((_, i) => (
48 <li key={i} className="h-6 rounded bg-gray-200" />
49 ))}
50 </ul>
51 );
52}
01 / 01
STEP 01
‹ swipe to step through ›
Walkthrough
Space play
←→ step
click any line
Three takeaways
- 1Wrapping a slow async component in Suspense lets the page shell render immediately while data streams in behind a fallback.
- 2Keying Suspense on the query forces a fresh fallback whenever the search term changes, avoiding stale results.
- 3Server Components can await data directly, keeping fetching logic on the server and out of the client bundle.
Related explainers
go
func (h *ExportHandler) StreamExport(c *gin.Context) { datasetID := c.Param("id") ctx := c.Request.Context()
Streaming NDJSON progress with Gin
streaming
goroutines
channels
Advanced
8 steps
javascript
import { useState } from 'react'; function StarRating({ value = 0, max = 5, onChange, size = 24 }) { const [hovered, setHovered] = useState(null);
Building an accessible star rating in React
controlled-component
accessibility
state-management
Intermediate
8 steps
javascript
import { notFound } from 'next/navigation' import { Suspense } from 'react' import { getPostBySlug, getRelatedPosts } from '@/lib/posts' import { RelatedPosts } from '@/components/related-posts'
Building a dynamic blog post page in Next.js
server-components
dynamic-routing
metadata
Intermediate
8 steps
javascript
const express = require('express'); const { createProxyMiddleware, fixRequestBody } = require('http-proxy-middleware'); const router = express.Router();
Building an API gateway proxy in Express
reverse-proxy
middleware
api-gateway
Intermediate
6 steps
javascript
import { useCallback, useRef, useState } from 'react'; export function ColorPicker({ initialColor = '#3b82f6', onCommit }) { const [committed, setCommitted] = useState(initialColor);
A validated color picker in React
uncontrolled-inputs
refs
validation
Intermediate
7 steps
javascript
import { useEffect, useRef } from 'react'; export function useRefetchOnFocus(refetch, { staleTime = 30_000 } = {}) { const lastFetchedAt = useRef(Date.now());
A React hook that refetches on tab focus
custom-hooks
refs
event-listeners
Intermediate
6 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-search-results-in-a-next-js-server-component-explained-javascript-996d/embed?autoplay=1" width="100%" height="520" loading="lazy" style="border:0"></iframe>
Autoplay is on by default — add ?autoplay=0 to start paused.