Next.js Server State
Introduction
Server state represents data that resides on the server and is typically fetched from external sources like databases, APIs, or other services. Next.js 13+ introduced revolutionary approaches to handling server state through React Server Components (RSC) and other server-side features, fundamentally changing how we think about data fetching and state management.
In this guide, we'll explore how Next.js handles server state, the benefits of this approach, and patterns for effectively working with server-side data in your applications.
React Server Components and Server State
React Server Components, a core feature of Next.js's App Router, enable components to run exclusively on the server. This paradigm shift offers several advantages for state management:
Understanding Server Components
Server Components are React components that:
- Execute only on the server
- Don't include client-side JavaScript in the bundle
- Can directly access server resources (databases, file systems)
- Cannot use hooks or browser APIs
// app/users/page.js
// This entire component runs on the server
async function UsersPage() {
// Fetch data directly in the component
const users = await fetch('https://api.example.com/users').then(res => res.json());
return (
<div>
<h1>Users</h1>
<ul>
{users.map(user => (
<li key={user.id}>{user.name}</li>
))}
</ul>
</div>
);
}
export default UsersPage;
In the example above, the data fetching happens on the server, and only the resulting HTML is sent to the client, significantly reducing the JavaScript bundle size.
Data Fetching in Server Components
Next.js provides multiple ways to fetch and manage server state:
1. Direct Data Fetching
The simplest approach is to fetch data directly in your Server Component:
// app/products/page.js
async function ProductsPage() {
// This fetch happens on the server
const products = await fetch('https://api.example.com/products').then(res => res.json());
return (
<div>
<h1>Products</h1>
<div className="product-grid">
{products.map(product => (
<ProductCard key={product.id} product={product} />
))}
</div>
</div>
);
}
Next.js automatically deduplicates fetch requests with the same URL and options, making it efficient to fetch the same data in multiple components.
2. Using Data Fetching Functions
For more organization, you can extract data fetching logic into separate functions:
// lib/api.js
export async function getProducts() {
const res = await fetch('https://api.example.com/products');
if (!res.ok) throw new Error('Failed to fetch products');
return res.json();
}
// app/products/page.js
import { getProducts } from '@/lib/api';
async function ProductsPage() {
const products = await getProducts();
return (
<div>
<h1>Products</h1>
<div className="product-grid">
{products.map(product => (
<ProductCard key={product.id} product={product} />
))}
</div>
</div>
);
}
Caching and Revalidation
Next.js provides powerful caching mechanisms for server state, enabling you to control how and when data is refreshed.
Static Data Fetching (Default)
By default, fetch
requests in Next.js are cached indefinitely, optimizing performance and reducing database load:
// This data is fetched at build time and cached
async function BlogPage() {
const posts = await fetch('https://api.example.com/posts').then(res => res.json());
return (/* render posts */);
}
Dynamic Data Fetching
For data that changes frequently, you can disable caching:
// This data is fetched on every request
async function StockPrices() {
const stocks = await fetch('https://api.example.com/stocks', {
cache: 'no-store'
}).then(res => res.json());
return (/* render stocks */);
}
Revalidation Strategies
Next.js offers two revalidation approaches:
Time-based Revalidation:
// Revalidate data every 60 seconds
async function ProductList() {
const products = await fetch('https://api.example.com/products', {
next: { revalidate: 60 }
}).then(res => res.json());
return (/* render products */);
}
On-demand Revalidation:
// app/api/revalidate/route.js
import { revalidatePath } from 'next/cache';
export async function POST(request) {
const { path, token } = await request.json();
// Validate secret token (security check)
if (token !== process.env.REVALIDATION_TOKEN) {
return Response.json({ message: 'Invalid token' }, { status: 401 });
}
// Revalidate the specific path
revalidatePath(path);
return Response.json({ revalidated: true, now: Date.now() });
}
Handling Loading and Error States
Next.js provides special files to handle loading and error states:
Loading State
// app/products/loading.js
export default function Loading() {
return (
<div className="loading-spinner">
<div className="spinner"></div>
<p>Loading products...</p>
</div>
);
}
Error Handling
// app/products/error.js
'use client';
import { useEffect } from 'react';
export default function Error({ error, reset }) {
useEffect(() => {
// Log the error to an error reporting service
console.error(error);
}, [error]);
return (
<div className="error-container">
<h2>Something went wrong!</h2>
<button onClick={() => reset()}>Try again</button>
</div>
);
}
Streaming with Suspense
Server Components support streaming, allowing you to progressively render parts of your page as data becomes available:
// app/dashboard/page.js
import { Suspense } from 'react';
import RevenueChart from './revenue-chart';
import LatestOrders from './latest-orders';
export default function Dashboard() {
return (
<div className="dashboard">
<h1>Dashboard</h1>
<div className="dashboard-grid">
<Suspense fallback={<div className="skeleton">Loading chart...</div>}>
<RevenueChart />
</Suspense>
<Suspense fallback={<div className="skeleton">Loading orders...</div>}>
<LatestOrders />
</Suspense>
</div>
</div>
);
}
Bridging Server and Client State
Sometimes you'll need to pass server state to client components:
// app/products/[id]/page.js
import ProductDetails from './product-details';
async function ProductPage({ params }) {
// Fetch on server
const product = await fetch(`https://api.example.com/products/${params.id}`).then(res => res.json());
// Pass as props to client component
return <ProductDetails product={product} />;
}
// app/products/[id]/product-details.js
'use client';
import { useState } from 'react';
export default function ProductDetails({ product }) {
const [quantity, setQuantity] = useState(1);
return (
<div>
<h1>{product.name}</h1>
<p>${product.price}</p>
<div className="quantity-selector">
<button onClick={() => setQuantity(q => Math.max(1, q - 1))}>-</button>
<span>{quantity}</span>
<button onClick={() => setQuantity(q => q + 1)}>+</button>
</div>
<button
className="add-to-cart"
onClick={() => alert(`Added ${quantity} ${product.name} to cart!`)}
>
Add to Cart
</button>
</div>
);
}
Real-world Application: E-commerce Product Page
Let's combine the concepts to build an e-commerce product page:
// app/products/[slug]/page.js
import { notFound } from 'next/navigation';
import { Suspense } from 'react';
import ProductView from '@/components/product-view';
import RelatedProducts from '@/components/related-products';
import ProductReviews from '@/components/product-reviews';
import AddToCartButton from '@/components/add-to-cart-button';
async function getProduct(slug) {
const res = await fetch(`https://api.example.com/products/${slug}`, {
next: { revalidate: 3600 } // Revalidate every hour
});
if (!res.ok) return null;
return res.json();
}
export default async function ProductPage({ params }) {
const product = await getProduct(params.slug);
if (!product) {
notFound();
}
return (
<div className="product-page">
<div className="product-main">
<ProductView product={product} />
<AddToCartButton product={product} />
</div>
<Suspense fallback={<div className="loading-related">Loading related products...</div>}>
<RelatedProducts categoryId={product.categoryId} currentProductId={product.id} />
</Suspense>
<Suspense fallback={<div className="loading-reviews">Loading reviews...</div>}>
<ProductReviews productId={product.id} />
</Suspense>
</div>
);
}
// components/add-to-cart-button.js
'use client';
import { useState } from 'react';
import { useCart } from '@/hooks/use-cart';
export default function AddToCartButton({ product }) {
const [quantity, setQuantity] = useState(1);
const { addToCart, isLoading } = useCart();
return (
<div className="add-to-cart-container">
<div className="quantity-selector">
<button onClick={() => setQuantity(q => Math.max(1, q - 1))}>-</button>
<input
type="number"
value={quantity}
onChange={(e) => setQuantity(Number(e.target.value))}
min="1"
/>
<button onClick={() => setQuantity(q => q + 1)}>+</button>
</div>
<button
className="add-to-cart-btn"
disabled={isLoading}
onClick={() => addToCart(product.id, quantity)}
>
{isLoading ? 'Adding...' : `Add to Cart - $${(product.price * quantity).toFixed(2)}`}
</button>
</div>
);
}
Database Access in Server Components
With Server Components, you can directly access databases without exposing connection details to clients:
// lib/db.js
import { PrismaClient } from '@prisma/client';
const prisma = global.prisma || new PrismaClient();
if (process.env.NODE_ENV !== 'production') global.prisma = prisma;
export default prisma;
// app/admin/products/page.js
import prisma from '@/lib/db';
import { revalidatePath } from 'next/cache';
export default async function AdminProductsPage() {
const products = await prisma.product.findMany({
orderBy: { createdAt: 'desc' },
});
async function addProduct(formData) {
'use server'; // Mark as server action
const name = formData.get('name');
const price = parseFloat(formData.get('price'));
await prisma.product.create({
data: { name, price }
});
revalidatePath('/admin/products');
}
return (
<div className="admin-products">
<h1>Product Management</h1>
<form action={addProduct}>
<input name="name" placeholder="Product name" required />
<input name="price" type="number" step="0.01" placeholder="Price" required />
<button type="submit">Add Product</button>
</form>
<table className="products-table">
<thead>
<tr>
<th>ID</th>
<th>Name</th>
<th>Price</th>
</tr>
</thead>
<tbody>
{products.map(product => (
<tr key={product.id}>
<td>{product.id}</td>
<td>{product.name}</td>
<td>${product.price.toFixed(2)}</td>
</tr>
))}
</tbody>
</table>
</div>
);
}
Summary
Next.js server state through React Server Components represents a paradigm shift in state management:
- Server Components run only on the server, enabling direct data access without client-side JavaScript
- Efficient data fetching with automatic request deduplication and built-in caching
- Flexible revalidation strategies to keep data fresh while maintaining performance
- Progressive rendering with Suspense for improved user experience
- Direct database access on the server without exposing credentials to clients
This approach shifts much of the data fetching and state management to the server, reducing client-side complexity, improving performance, and enhancing security.
Additional Resources
- Next.js Data Fetching Documentation
- React Server Components Documentation
- Next.js Caching Documentation
- Prisma with Next.js Tutorial
Practice Exercises
- Create a blog page that fetches posts from an API and implements time-based revalidation.
- Build a dashboard with multiple data sections using Suspense for streaming.
- Implement an admin interface that directly connects to a database and allows creating and updating records.
- Create a product page with client-side interactivity for adding items to a cart, while keeping product data server-side.
- Build a comments section that uses server components for initial rendering and client components for adding new comments.
If you spot any mistakes on this website, please let me know at [email protected]. I’d greatly appreciate your feedback! :)