Next.js Server Components
A product catalog built with Next.js App Router that demonstrates the React Server Component model. Server components fetch data directly with async/await — no useEffect, no loading spinners, no API layer. A client component adds interactivity (search), and Suspense streams a slow server component so the rest of the page isn’t blocked.
How it works
The page is an async server component. It awaits getProducts() and passes the result as props to a client ProductSearch component, which adds filtering. A separate Stats server component runs its own slow query — it’s wrapped in Suspense so the page renders immediately and the stats stream in when ready. The boundary between server and client is the 'use client' directive at the top of product-search.tsx.
nextjs-server-components/
├── lib/db.ts ← simulated async database (two queries, one slow)
├── app/layout.tsx ← root layout (minimal)
├── app/page.tsx ← async server component: fetches data, composes page
├── app/stats.tsx ← async server component: slow query, streamed via Suspense
└── app/product-search.tsx ← 'use client': receives products as props, adds searchlib/db.ts
Simulated database with two async functions. getProducts returns quickly. getStats has an artificial 2-second delay to demonstrate Suspense streaming — in a real app this would be a slow aggregation query.
export type Product = {
id: number
name: string
category: string
price: number
}
export type Stats = {
total: number
categories: number
avgPrice: number
}
const products: Product[] = [
{ id: 1, name: 'Mechanical Keyboard', category: 'Peripherals', price: 129 },
{ id: 2, name: 'USB-C Hub', category: 'Peripherals', price: 49 },
{ id: 3, name: 'Noise-Cancelling Headphones', category: 'Audio', price: 299 },
{ id: 4, name: 'Webcam HD', category: 'Video', price: 79 },
{ id: 5, name: 'Standing Desk Mat', category: 'Furniture', price: 45 },
{ id: 6, name: 'Monitor Light Bar', category: 'Lighting', price: 59 },
{ id: 7, name: 'Wireless Mouse', category: 'Peripherals', price: 69 },
{ id: 8, name: 'Desk Microphone', category: 'Audio', price: 149 },
]
export async function getProducts(): Promise<Product[]> {
// Simulate database read
await new Promise((r) => setTimeout(r, 100))
return products
}
export async function getStats(): Promise<Stats> {
// Simulate a slow aggregation query
await new Promise((r) => setTimeout(r, 2000))
const categories = new Set(products.map((p) => p.category))
const avgPrice = products.reduce((sum, p) => sum + p.price, 0) / products.length
return { total: products.length, categories: categories.size, avgPrice }
}app/layout.tsx
Minimal root layout — just the HTML shell and the system font stack.
import type { ReactNode } from 'react'
export const metadata = { title: 'Next.js Server Components' }
export default function RootLayout({ children }: { children: ReactNode }) {
return (
<html lang="en">
<body style={{ fontFamily: 'system-ui, sans-serif', maxWidth: 560, margin: '2rem auto' }}>
{children}
</body>
</html>
)
}app/page.tsx
This is an async server component — the default in Next.js App Router. It calls getProducts() directly (no fetch, no API route) and passes the result as props to the client ProductSearch. The Stats component is another async server component wrapped in Suspense — it won’t block the page from rendering. When the slow query finishes, React streams the HTML in and replaces the fallback.
import { Suspense } from 'react'
import { getProducts } from '../lib/db'
import { Stats } from './stats'
import { ProductSearch } from './product-search'
export default async function Page() {
const products = await getProducts()
return (
<main>
<h1>Products</h1>
<Suspense fallback={<p>Loading stats...</p>}>
<Stats />
</Suspense>
<hr style={{ margin: '1.5rem 0' }} />
<ProductSearch products={products} />
</main>
)
}app/stats.tsx
An async server component that runs its own data fetch. Because it’s wrapped in Suspense in the parent, the rest of the page renders immediately — the browser shows “Loading stats…” and then this component streams in once getStats() resolves. No client JavaScript involved.
import { getStats } from '../lib/db'
export async function Stats() {
const stats = await getStats()
return (
<div style={{ display: 'flex', gap: '2rem', fontSize: '0.9rem' }}>
<span><strong>{stats.total}</strong> products</span>
<span><strong>{stats.categories}</strong> categories</span>
<span><strong>${stats.avgPrice.toFixed(2)}</strong> avg price</span>
</div>
)
}app/product-search.tsx
'use client' marks the server/client boundary. Everything in this file runs in the browser. The products prop was fetched on the server and passed down — the client component never calls an API or runs a query. It just adds interactivity: a search input that filters the list by name or category. This is the core pattern — server components fetch, client components interact. Note the import type — type-only imports are erased at compile time so they’re safe to use from client components without pulling server code into the bundle.
'use client'
import { useState } from 'react'
import type { Product } from '../lib/db'
export function ProductSearch({ products }: { products: Product[] }) {
const [query, setQuery] = useState('')
const filtered = products.filter(
(p) =>
p.name.toLowerCase().includes(query.toLowerCase()) ||
p.category.toLowerCase().includes(query.toLowerCase())
)
return (
<>
<input
type="search"
placeholder="Search products or categories..."
value={query}
onChange={(e) => setQuery(e.target.value)}
style={{ width: '100%', padding: '0.5rem', marginBottom: '1rem', font: 'inherit' }}
/>
{filtered.length > 0 ? (
<ul style={{ listStyle: 'none', padding: 0 }}>
{filtered.map((p) => (
<li key={p.id} style={{ padding: '0.5rem 0', borderBottom: '1px solid #eee' }}>
<strong>{p.name}</strong>
<span style={{ color: '#666', marginLeft: '0.5rem' }}>{p.category}</span>
<span style={{ float: 'right' }}>${p.price}</span>
</li>
))}
</ul>
) : (
<p>No products match "{query}".</p>
)}
</>
)
}Run it
npx create-next-app@latest nextjs-server-components --ts --app --no-tailwind --no-eslint --no-src-dir
cd nextjs-server-components
mkdir lib
# create the files above
npm run devOpen http://localhost:3000 . The product list appears immediately while “Loading stats…” shows at the top. After two seconds the stats stream in. Type in the search box to filter — that runs entirely in the browser, no server round-trip.