Building Modern, Performant Data Layers in Next.js 15
Data fetching in Next.js 15 has evolved dramatically from the traditional useEffect and fetch patterns. With Server Actions enabling direct server-side mutations, API Routes providing RESTful endpoints, and React Query offering sophisticated client-side state management, developers now face a crucial question: which pattern should I use and when? Many intermediate developers struggle with choosing the right data fetching strategy, implementing efficient caching, handling optimistic updates, and building forms that feel instant while remaining reliable.
This comprehensive guide explores modern data fetching patterns in Next.js 15. You'll master Server Actions for seamless form handling and mutations, understand when to use API Routes versus direct database access, implement sophisticated caching strategies that balance freshness with performance, build CRUD operations that feel instant with optimistic updates, and integrate React Query for advanced client-side state management. By the end, you'll architect data layers that are both performant and maintainable, handling everything from simple forms to complex real-time dashboards.
Prerequisites
Before diving into advanced data fetching patterns, ensure you have:
- Next.js 15 installed - Latest version with stable Server Actions
- TypeScript knowledge - Interfaces, types, and async/await
- React fundamentals - Hooks, state management, component composition
- HTTP concepts - Understanding of REST APIs, status codes, headers
- Database basics - SQL or NoSQL familiarity helpful
- Promise handling - async/await, Promise.all, error handling
- Form handling experience - Basic HTML forms and validation
- Node.js 18.17+ and a package manager (npm/yarn/pnpm)
The Evolution of Data Fetching in Next.js
Data fetching in Next.js has evolved dramatically with the App Router. Gone are the days of getServerSideProps and getStaticProps. The modern Next.js paradigm embraces Server Components for data fetching, Server Actions for mutations, and sophisticated caching strategies that blur the line between static and dynamic content.
Many developers struggle with choosing the right data fetching pattern: Should you use Server Actions or API routes? When should you implement React Query? How do you handle optimistic updates? What's the best caching strategy? This comprehensive guide answers these questions and more, providing you with battle-tested patterns for building production-ready applications.
You'll learn how to fetch data efficiently in Server Components, handle form submissions with Server Actions, implement optimistic UI updates, build robust API routes, integrate React Query for client-side state management, and master Next.js caching strategies. By the end, you'll confidently choose the right data fetching pattern for any scenario and implement CRUD operations that feel instant while maintaining data integrity.
Prerequisites
Before diving into advanced data fetching patterns, ensure you have:
- Next.js 15 with App Router - Fresh installation recommended
- Understanding of Server and Client Components - Review Day 1 article if needed
- TypeScript proficiency - Comfortable with types and interfaces
- Database knowledge - Basic SQL or NoSQL understanding
- HTTP methods - Understanding of REST principles (GET, POST, PUT, DELETE)
- React hooks experience - useState, useEffect, useTransition
- Async/await familiarity - Promise handling and error management
- Node.js 18.17+ with npm/yarn/pnpm installed
The Evolution of Data Fetching in Next.js
Data fetching in Next.js 15 represents a paradigm shift from traditional patterns. Instead of creating API routes for every operation and fetching data on the client, you now have powerful server-side primitives that eliminate entire categories of boilerplate code. However, this shift introduces new questions: When should you use Server Actions versus API routes? How do you handle form submissions without JavaScript? What's the best way to implement optimistic updates? How do you manage cache invalidation across server and client boundaries?
This comprehensive guide explores modern data fetching patterns in Next.js 15. You'll master Server Actions for form handling and mutations, understand when to use traditional API routes, implement React Query for advanced client-side state management, build complete CRUD operations with proper error handling, create optimistic UI updates for instant feedback, and design robust caching strategies. By the end, you'll confidently choose the right data fetching pattern for any scenario and build applications that feel lightning-fast while remaining reliable.
Prerequisites
Before diving into advanced data fetching patterns, ensure you have:
- Next.js 15 project setup - App Router configured and running
- Understanding of Server Components - Review Day 1's article if needed
- TypeScript basics - Types, interfaces, and async/await
- HTTP methods knowledge - GET, POST, PUT, DELETE semantics
- Database or API access - For testing CRUD operations
- React hooks experience - useState, useEffect, useTransition
- Basic understanding of REST APIs - Request/response patterns
- Optional: React Query knowledge - Helpful but not required
The Evolution of Data Fetching in Next.js
Data fetching in Next.js has evolved dramatically with the App Router. Gone are the days of relying solely on getServerSideProps and getStaticProps. Next.js 15 introduces powerful new primitives: Server Actions for mutations, enhanced fetch API with automatic caching, and seamless integration with libraries like React Query for complex client-side state management.
This comprehensive guide explores modern data fetching patterns in Next.js 15. You'll learn how to perform CRUD operations using Server Actions, implement optimistic updates for instant UI feedback, manage complex caching strategies, integrate third-party libraries like React Query, and handle forms with progressive enhancement. By mastering these patterns, you'll build applications that feel instant while maintaining data consistency and proper error handling.
Prerequisites
Before diving into advanced data fetching patterns, ensure you have:
- Next.js 15 installed - Latest stable version with App Router
- Understanding of Server and Client Components - Know when to use each
- TypeScript knowledge - Types, interfaces, and async/await
- React fundamentals - Hooks, state management, and component lifecycle
- HTTP basics - Understanding of REST principles and status codes
- Database or API setup - For testing CRUD operations (PostgreSQL, MongoDB, or mock API)
- Knowledge of async/await - Promises and asynchronous JavaScript
The Modern Data Fetching Landscape
Next.js 15 revolutionizes data fetching by providing multiple approaches, each optimized for different use cases. Gone are the days when you had to choose between client-side fetching with useEffect or building API routes for every data operation. Now you have Server Components for initial data loading, Server Actions for mutations, Route Handlers for external API integration, and the option to integrate React Query for advanced client-side caching.
Understanding when and how to use each pattern is crucial for building performant, maintainable applications. This guide explores the complete data fetching landscape: direct database access in Server Components, Server Actions for mutations and form handling, Route Handlers for public APIs, and React Query integration for complex client-side data management. You'll learn to implement full CRUD operations, handle optimistic updates, manage caching strategies, and build forms that work with and without JavaScript.
Prerequisites
Before diving into data fetching patterns, ensure you have:
- Next.js 15 project setup - App Router with TypeScript
- Understanding of async/await - Promise handling and async functions
- React Server Components knowledge - Understanding from previous article recommended
- HTTP methods familiarity - GET, POST, PUT, DELETE operations
- Basic database knowledge - SQL or NoSQL concepts
- Form handling experience - Understanding of form submission basics
- TypeScript fundamentals - Types, interfaces, and async/await
The Evolution of Data Fetching in Next.js
Next.js 15 introduces a paradigm shift in how we handle data mutations and server-side logic. While traditional approaches relied heavily on API routes and client-side data fetching libraries, the App Router brings Server Actions—a revolutionary way to handle mutations directly from Server Components without explicit API endpoints.
Understanding the Data Fetching Landscape
Modern Next.js applications have multiple options for data fetching:
- Server Components - Direct database queries and API calls on the server
- Server Actions - Server-side mutations called from Client Components
- Route Handlers - Traditional API endpoints for REST APIs
- Client-side fetching - Using libraries like React Query for complex client state
Each approach has specific use cases, and understanding when to use each pattern is crucial for building efficient applications.
Prerequisites
Before diving into data fetching patterns, ensure you have:
- Next.js 15 installed - With App Router enabled
- TypeScript experience - Understanding types and interfaces
- React fundamentals - Hooks, props, component lifecycle
- HTTP knowledge - REST principles, status codes, request methods
- Database basics - SQL or NoSQL query concepts
- Async JavaScript - Promises, async/await patterns
- Form handling experience - HTML forms and validation
Server Actions: The Modern Way to Handle Mutations
Server Actions are asynchronous functions that run on the server. They provide a seamless way to handle form submissions and mutations without creating API routes.
Basic Server Action
// app/actions/posts.ts
'use server';
import { revalidatePath } from 'next/cache';
import { redirect } from 'next/navigation';
export async function createPost(formData: FormData) {
const title = formData.get('title') as string;
const content = formData.get('content') as string;
// Validate input
if (!title || title.length < 3) {
return { error: 'Title must be at least 3 characters' };
}
if (!content || content.length < 10) {
return { error: 'Content must be at least 10 characters' };
}
try {
// Create post in database
const post = await db.posts.create({
data: { title, content }
});
// Revalidate the posts page to show new post
revalidatePath('/posts');
// Redirect to the new post
redirect(`/posts/${post.id}`);
} catch (error) {
return { error: 'Failed to create post' };
}
}
// app/posts/create/page.tsx
import { createPost } from '@/app/actions/posts';
import { SubmitButton } from '@/components/SubmitButton';
export default function CreatePostPage() {
return (
<form action={createPost}>
<div>
<label htmlFor="title">Title</label>
<input
type="text"
id="title"
name="title"
required
/>
</div>
<div>
<label htmlFor="content">Content</label>
<textarea
id="content"
name="content"
rows={10}
required
/>
</div>
<SubmitButton />
</form>
);
}
// components/SubmitButton.tsx
'use client';
import { useFormStatus } from 'react-dom';
export function SubmitButton() {
const { pending } = useFormStatus();
return (
<button type="submit" disabled={pending}>
{pending ? 'Creating...' : 'Create Post'}
</button>
);
}
Server Actions with useFormState
For better error handling and user feedback:
// app/actions/posts.ts
'use server';
import { revalidatePath } from 'next/cache';
export type FormState = {
message: string;
errors?: {
title?: string[];
content?: string[];
};
success?: boolean;
};
export async function createPost(
prevState: FormState,
formData: FormData
): Promise<FormState> {
const title = formData.get('title') as string;
const content = formData.get('content') as string;
// Validation
const errors: FormState['errors'] = {};
if (!title || title.length < 3) {
errors.title = ['Title must be at least 3 characters'];
}
if (!content || content.length < 10) {
errors.content = ['Content must be at least 10 characters'];
}
if (Object.keys(errors).length > 0) {
return {
message: 'Validation failed',
errors,
};
}
try {
await db.posts.create({
data: { title, content }
});
revalidatePath('/posts');
return {
message: 'Post created successfully!',
success: true,
};
} catch (error) {
return {
message: 'Failed to create post',
errors: { title: ['Database error occurred'] },
};
}
}
// app/posts/create/page.tsx
'use client';
import { useFormState } from 'react-dom';
import { createPost, FormState } from '@/app/actions/posts';
import { SubmitButton } from '@/components/SubmitButton';
import { useEffect } from 'react';
import { useRouter } from 'next/navigation';
const initialState: FormState = {
message: '',
};
export default function CreatePostPage() {
const [state, formAction] = useFormState(createPost, initialState);
const router = useRouter();
useEffect(() => {
if (state.success) {
router.push('/posts');
}
}, [state.success, router]);
return (
<form action={formAction}>
<div>
<label htmlFor="title">Title</label>
<input
type="text"
id="title"
name="title"
required
/>
{state.errors?.title && (
<p className="error">{state.errors.title[0]}</p>
)}
</div>
<div>
<label htmlFor="content">Content</label>
<textarea
id="content"
name="content"
rows={10}
required
/>
{state.errors?.content && (
<p className="error">{state.errors.content[0]}</p>
)}
</div>
{state.message && !state.success && (
<p className="error">{state.message}</p>
)}
<SubmitButton />
</form>
);
}
Progressive Enhancement with Server Actions
Server Actions work without JavaScript, providing excellent progressive enhancement:
// app/actions/todos.ts
'use server';
import { revalidatePath } from 'next/cache';
export async function toggleTodo(formData: FormData) {
const todoId = formData.get('todoId') as string;
const completed = formData.get('completed') === 'true';
await db.todos.update({
where: { id: todoId },
data: { completed: !completed }
});
revalidatePath('/todos');
}
export async function deleteTodo(formData: FormData) {
const todoId = formData.get('todoId') as string;
await db.todos.delete({
where: { id: todoId }
});
revalidatePath('/todos');
}
// app/todos/page.tsx
import { toggleTodo, deleteTodo } from '@/app/actions/todos';
async function getTodos() {
return db.todos.findMany();
}
export default async function TodosPage() {
const todos = await getTodos();
return (
<div>
<h1>My Todos</h1>
<ul>
{todos.map(todo => (
<li key={todo.id}>
<form action={toggleTodo} style={{ display: 'inline' }}>
<input type="hidden" name="todoId" value={todo.id} />
<input type="hidden" name="completed" value={String(todo.completed)} />
<button type="submit">
{todo.completed ? '✓' : '○'}
</button>
</form>
<span style={{
textDecoration: todo.completed ? 'line-through' : 'none'
}}>
{todo.title}
</span>
<form action={deleteTodo} style={{ display: 'inline' }}>
<input type="hidden" name="todoId" value={todo.id} />
<button type="submit">Delete</button>
</form>
</li>
))}
</ul>
</div>
);
}
Server Actions with useOptimistic
Provide instant feedback with optimistic updates:
// app/todos/TodoList.tsx
'use client';
import { useOptimistic } from 'react';
import { toggleTodo } from '@/app/actions/todos';
type Todo = {
id: string;
title: string;
completed: boolean;
};
export function TodoList({ todos }: { todos: Todo[] }) {
const [optimisticTodos, addOptimisticTodo] = useOptimistic(
todos,
(state, { id, completed }: { id: string; completed: boolean }) => {
return state.map(todo =>
todo.id === id ? { ...todo, completed } : todo
);
}
);
async function handleToggle(id: string, completed: boolean) {
// Immediately update UI
addOptimisticTodo({ id, completed: !completed });
// Then perform server action
const formData = new FormData();
formData.append('todoId', id);
formData.append('completed', String(completed));
await toggleTodo(formData);
}
return (
<ul>
{optimisticTodos.map(todo => (
<li key={todo.id}>
<button onClick={() => handleToggle(todo.id, todo.completed)}>
{todo.completed ? '✓' : '○'}
</button>
<span style={{
textDecoration: todo.completed ? 'line-through' : 'none',
opacity: todo.completed ? 0.6 : 1
}}>
{todo.title}
</span>
</li>
))}
</ul>
);
}
// app/todos/page.tsx
import { TodoList } from './TodoList';
async function getTodos() {
return db.todos.findMany();
}
export default async function TodosPage() {
const todos = await getTodos();
return (
<div>
<h1>My Todos</h1>
<TodoList todos={todos} />
</div>
);
}
API Routes: RESTful Endpoints
While Server Actions are preferred for mutations, Route Handlers are perfect for building RESTful APIs.
Complete CRUD API
// app/api/products/route.ts
import { NextRequest, NextResponse } from 'next/server';
import { z } from 'zod';
const productSchema = z.object({
name: z.string().min(3),
price: z.number().positive(),
description: z.string().optional(),
categoryId: z.string(),
});
// GET /api/products - List all products
export async function GET(request: NextRequest) {
try {
const searchParams = request.nextUrl.searchParams;
const category = searchParams.get('category');
const page = parseInt(searchParams.get('page') || '1');
const limit = parseInt(searchParams.get('limit') || '10');
const where = category ? { categoryId: category } : {};
const [products, total] = await Promise.all([
db.products.findMany({
where,
skip: (page - 1) * limit,
take: limit,
include: { category: true },
}),
db.products.count({ where }),
]);
return NextResponse.json({
success: true,
data: products,
pagination: {
page,
limit,
total,
totalPages: Math.ceil(total / limit),
},
});
} catch (error) {
return NextResponse.json(
{ success: false, error: 'Failed to fetch products' },
{ status: 500 }
);
}
}
// POST /api/products - Create a product
export async function POST(request: NextRequest) {
try {
const body = await request.json();
// Validate input
const validatedData = productSchema.parse(body);
// Check authorization (example)
const authHeader = request.headers.get('authorization');
if (!authHeader) {
return NextResponse.json(
{ success: false, error: 'Unauthorized' },
{ status: 401 }
);
}
const product = await db.products.create({
data: validatedData,
});
return NextResponse.json(
{ success: true, data: product },
{ status: 201 }
);
} catch (error) {
if (error instanceof z.ZodError) {
return NextResponse.json(
{ success: false, error: 'Validation failed', details: error.errors },
{ status: 400 }
);
}
return NextResponse.json(
{ success: false, error: 'Failed to create product' },
{ status: 500 }
);
}
}
// app/api/products/[id]/route.ts
import { NextRequest, NextResponse } from 'next/server';
import { z } from 'zod';
const updateProductSchema = z.object({
name: z.string().min(3).optional(),
price: z.number().positive().optional(),
description: z.string().optional(),
categoryId: z.string().optional(),
});
interface RouteContext {
params: { id: string };
}
// GET /api/products/[id] - Get single product
export async function GET(
request: NextRequest,
{ params }: RouteContext
) {
try {
const product = await db.products.findUnique({
where: { id: params.id },
include: {
category: true,
reviews: {
take: 5,
orderBy: { createdAt: 'desc' },
},
},
});
if (!product) {
return NextResponse.json(
{ success: false, error: 'Product not found' },
{ status: 404 }
);
}
return NextResponse.json({
success: true,
data: product,
});
} catch (error) {
return NextResponse.json(
{ success: false, error: 'Failed to fetch product' },
{ status: 500 }
);
}
}
// PATCH /api/products/[id] - Update product
export async function PATCH(
request: NextRequest,
{ params }: RouteContext
) {
try {
const body = await request.json();
const validatedData = updateProductSchema.parse(body);
const product = await db.products.update({
where: { id: params.id },
data: validatedData,
});
return NextResponse.json({
success: true,
data: product,
});
} catch (error) {
if (error instanceof z.ZodError) {
return NextResponse.json(
{ success: false, error: 'Validation failed', details: error.errors },
{ status: 400 }
);
}
return NextResponse.json(
{ success: false, error: 'Failed to update product' },
{ status: 500 }
);
}
}
// DELETE /api/products/[id] - Delete product
export async function DELETE(
request: NextRequest,
{ params }: RouteContext
) {
try {
await db.products.delete({
where: { id: params.id },
});
return NextResponse.json({
success: true,
message: 'Product deleted successfully',
});
} catch (error) {
return NextResponse.json(
{ success: false, error: 'Failed to delete product' },
{ status: 500 }
);
}
}
API Route with Authentication
// lib/auth.ts
import { NextRequest } from 'next/server';
import { jwtVerify } from 'jose';
export async function verifyAuth(request: NextRequest) {
const token = request.headers.get('authorization')?.replace('Bearer ', '');
if (!token) {
return null;
}
try {
const secret = new TextEncoder().encode(process.env.JWT_SECRET);
const { payload } = await jwtVerify(token, secret);
return payload;
} catch (error) {
return null;
}
}
// app/api/profile/route.ts
import { NextRequest, NextResponse } from 'next/server';
import { verifyAuth } from '@/lib/auth';
export async function GET(request: NextRequest) {
const user = await verifyAuth(request);
if (!user) {
return NextResponse.json(
{ success: false, error: 'Unauthorized' },
{ status: 401 }
);
}
const profile = await db.users.findUnique({
where: { id: user.id as string },
select: {
id: true,
email: true,
name: true,
createdAt: true,
},
});
return NextResponse.json({
success: true,
data: profile,
});
}
export async function PATCH(request: NextRequest) {
const user = await verifyAuth(request);
if (!user) {
return NextResponse.json(
{ success: false, error: 'Unauthorized' },
{ status: 401 }
);
}
const body = await request.json();
const updatedProfile = await db.users.update({
where: { id: user.id as string },
data: {
name: body.name,
},
select: {
id: true,
email: true,
name: true,
},
});
return NextResponse.json({
success: true,
data: updatedProfile,
});
}
React Query Integration for Client-Side Data Fetching
React Query (TanStack Query) provides powerful client-side state management for server data.
Setting Up React Query
// app/providers.tsx
'use client';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { ReactNode, useState } from 'react';
export function Providers({ children }: { children: ReactNode }) {
const [queryClient] = useState(
() =>
new QueryClient({
defaultOptions: {
queries: {
staleTime: 60 * 1000, // 1 minute
refetchOnWindowFocus: false,
},
},
})
);
return (
<QueryClientProvider client={queryClient}>
{children}
</QueryClientProvider>
);
}
// app/layout.tsx
import { Providers } from './providers';
export default function RootLayout({ children }: { children: ReactNode }) {
return (
<html lang="en">
<body>
<Providers>
{children}
</Providers>
</body>
</html>
);
}
Queries with React Query
// lib/api/products.ts
export async function fetchProducts(page: number = 1) {
const res = await fetch(`/api/products?page=${page}&limit=10`);
if (!res.ok) throw new Error('Failed to fetch products');
return res.json();
}
export async function fetchProduct(id: string) {
const res = await fetch(`/api/products/${id}`);
if (!res.ok) throw new Error('Failed to fetch product');
return res.json();
}
// app/products/ProductList.tsx
'use client';
import { useQuery } from '@tanstack/react-query';
import { fetchProducts } from '@/lib/api/products';
import { useState } from 'react';
export function ProductList() {
const [page, setPage] = useState(1);
const { data, isLoading, error } = useQuery({
queryKey: ['products', page],
queryFn: () => fetchProducts(page),
});
if (isLoading) {
return <div>Loading products...</div>;
}
if (error) {
return <div>Error loading products: {error.message}</div>;
}
return (
<div>
<div className="product-grid">
{data.data.map((product: any) => (
<div key={product.id} className="product-card">
<h3>{product.name}</h3>
<p>${product.price}</p>
</div>
))}
</div>
<div className="pagination">
<button
onClick={() => setPage(p => Math.max(1, p - 1))}
disabled={page === 1}
>
Previous
</button>
<span>Page {page} of {data.pagination.totalPages}</span>
<button
onClick={() => setPage(p => p + 1)}
disabled={page >= data.pagination.totalPages}
>
Next
</button>
</div>
</div>
);
}
Mutations with React Query
// lib/api/products.ts
export async function createProduct(data: {
name: string;
price: number;
description?: string;
categoryId: string;
}) {
const res = await fetch('/api/products', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data),
});
if (!res.ok) throw new Error('Failed to create product');
return res.json();
}
export async function updateProduct(id: string, data: Partial<{
name: string;
price: number;
description: string;
categoryId: string;
}>) {
const res = await fetch(`/api/products/${id}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data),
});
if (!res.ok) throw new Error('Failed to update product');
return res.json();
}
export async function deleteProduct(id: string) {
const res = await fetch(`/api/products/${id}`, {
method: 'DELETE',
});
if (!res.ok) throw new Error('Failed to delete product');
return res.json();
}
// app/products/CreateProductForm.tsx
'use client';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { createProduct } from '@/lib/api/products';
import { useState } from 'react';
export function CreateProductForm() {
const queryClient = useQueryClient();
const [name, setName] = useState('');
const [price, setPrice] = useState('');
const [categoryId, setCategoryId] = useState('');
const mutation = useMutation({
mutationFn: createProduct,
onSuccess: () => {
// Invalidate and refetch products list
queryClient.invalidateQueries({ queryKey: ['products'] });
// Reset form
setName('');
setPrice('');
setCategoryId('');
},
});
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
mutation.mutate({
name,
price: parseFloat(price),
categoryId,
});
};
return (
<form onSubmit={handleSubmit}>
<div>
<label>Name</label>
<input
value={name}
onChange={e => setName(e.target.value)}
required
/>
</div>
>
<label>Price</label>
<input
type="number"
step="0.01"
value={price}
onChange={e => setPrice(e.target.value)}
required
/>
</div>
<div>
<label>Category ID</label>
<input
value={categoryId}
onChange={e => setCategoryId(e.target.value)}
required
/>
</div>
{mutation.error && (
<p className="error">{mutation.error.message}</p>
)}
<button type="submit" disabled={mutation.isPending}>
{mutation.isPending ? 'Creating...' : 'Create Product'}
</button>
{mutation.isSuccess && (
<p className="success">Product created successfully!</p>
)}
</form>
);
}
Optimistic Updates with React Query
// app/products/ProductCard.tsx
'use client';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { deleteProduct } from '@/lib/api/products';
type Product = {
id: string;
name: string;
price: number;
};
export function ProductCard({ product }: { product: any }) {
const queryClient = useQueryClient();
const deleteMutation = useMutation({
mutationFn: deleteProduct,
onMutate: async (productId) => {
// Cancel outgoing refetches
await queryClient.cancelQueries({ queryKey: ['products'] });
// Snapshot previous value
const previousProducts = queryClient.getQueryData(['products']);
// Optimistically update
queryClient.setQueryData(['products'], (old: any) => ({
...old,
data: old.data.filter((p: any) => p.id !== productId),
}));
// Return context for rollback
return { previousProducts: previousData };
},
onError: (err, variables, context) => {
// Rollback on error
if (context?.previousProducts) {
queryClient.setQueryData(['products', page], context.previousProducts);
}
},
onSettled: () => {
// Always refetch after error or success
queryClient.invalidateQueries({ queryKey: ['products'] });
},
});
return (
<button
onClick={() => mutation.mutate(productId)}
disabled={mutation.isPending}
>
{mutation.isPending ? 'Deleting...' : 'Delete'}
</button>
);
}
Optimistic Updates with React Query
// app/products/ProductCard.tsx
'use client';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { deleteProduct } from '@/lib/api/products';
export function ProductCard({ product }: { product: any }) {
const queryClient = useQueryClient();
const deleteMutation = useMutation({
mutationFn: deleteProduct,
onMutate: async (productId) => {
// Cancel outgoing refetches
await queryClient.cancelQueries({ queryKey: ['products'] });
// Snapshot previous value
const previousProducts = queryClient.getQueryData(['products']);
// Optimistically remove the product
queryClient.setQueryData(['products'], (old: any) => ({
...old,
data: old.data.filter((p: any) => p.id !== productId),
}));
return { previousProducts: data };
},
onError: (err, variables, context) => {
// Rollback on error
queryClient.setQueryData(['products'], context?.previousProducts);
},
onSettled: () => {
// Refetch after error or success
queryClient.invalidateQueries({ queryKey: ['products'] });
},
});
return (
<button
onClick={() => mutation.mutate(productId)}
disabled={mutation.isPending}
>
{mutation.isPending ? 'Deleting...' : 'Delete'}
</button>
);
}
Optimistic Updates with React Query
// app/products/ProductCard.tsx
'use client';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { updateProduct } from '@/lib/api/products';
type Product = {
id: string;
name: string;
price: number;
isFavorite: boolean;
};
export function ProductCard({ product }: { product: any }) {
const queryClient = useQueryClient();
const toggleFavorite = useMutation({
mutationFn: (id: string) =>
updateProduct(id, { isFavorite: !product.isFavorite }),
// Optimistic update
onMutate: async () => {
// Cancel outgoing refetches
await queryClient.cancelQueries({ queryKey: ['products'] });
// Snapshot the previous value
const previousProducts = queryClient.getQueryData(['products']);
// Optimistically update to the new value
queryClient.setQueryData(['products'], (old: any) => ({
...old,
data: old.data.map((p: any) =>
p.id === product.id
? { ...p, isFavorite: !p.isFavorite }
: p
),
}));
return { previousProducts: previous };
},
onError: (err, variables, context) => {
// Rollback on error
if (context?.previousProducts) {
queryClient.setQueryData(['products'], context.previousProducts);
}
},
onSettled: () => {
// Refetch after error or success
queryClient.invalidateQueries({ queryKey: ['products'] });
},
});
return (
<button
onClick={() => mutation.mutate(productId)}
disabled={mutation.isPending}
>
{mutation.isPending ? 'Adding...' : 'Add to Cart'}
</button>
);
}
Optimistic Updates with React Query
// app/products/ProductItem.tsx
'use client';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { updateProduct } from '@/lib/api/products';
type Product = {
id: string;
name: string;
price: number;
stock: number;
};
export function ProductItem({ product }: { product: Product }) {
const queryClient = useQueryClient();
const mutation = useMutation({
mutationFn: (newStock: number) =>
updateProduct(product.id, { stock: newStock }),
onMutate: async (newStock) => {
// Cancel outgoing refetches
await queryClient.cancelQueries({ queryKey: ['products'] });
// Snapshot previous value
const previousProducts = queryClient.getQueryData(['products']);
// Optimistically update
queryClient.setQueryData(['products'], (old: any) => ({
...old,
data: old.data.map((p: any) =>
p.id === product.id ? { ...p, stock: newStock } : p
),
}));
return { previousProducts };
},
onError: (err, variables, context) => {
// Rollback on error
queryClient.setQueryData(['products'], context?.previousProducts);
},
onSettled: () => {
// Refetch after error or success
queryClient.invalidateQueries({ queryKey: ['products'] });
},
});
return (
<button onClick={() => mutation.mutate(productId)}>
{mutation.isPending ? 'Updating...' : 'Update Stock'}
</button>
);
}
Caching Strategies
Next.js 15 provides powerful caching mechanisms that work seamlessly with all data fetching approaches.
Fetch API Caching
// Revalidate every hour
async function getProducts() {
const res = await fetch('https://api.example.com/products', {
next: { revalidate: 3600 }
});
return res.json();
}
// Never cache (always fresh)
async function getCurrentUser() {
const res = await fetch('https://api.example.com/user', {
cache: 'no-store'
});
return res.json();
}
// Cache forever
async function getConfig() {
const res = await fetch('https://api.example.com/config', {
cache: 'force-cache'
});
return res.json();
}
Tag-Based Revalidation
// lib/data.ts
export async function getPost(slug: string) {
const res = await fetch(`https://api.example.com/posts/${slug}`, {
next: {
tags: [`post-${slug}`, 'posts'],
revalidate: 3600
}
});
return res.json();
}
// app/actions/posts.ts
'use server';
import { revalidateTag } from 'next/cache';
export async function updatePost(slug: string, data: any) {
await db.posts.update({
where: { slug },
data
});
// Revalidate specific post
revalidateTag(`post-${slug}`);
// Revalidate all posts list
revalidateTag('posts');
}
Path-Based Revalidation
// app/actions/posts.ts
'use server';
import { revalidatePath } from 'next/cache';
export async function createPost(data: any) {
const post = await db.posts.create({ data });
// Revalidate the posts list page
revalidatePath('/posts');
// Revalidate the new post page
revalidatePath(`/posts/${post.slug}`);
return post;
}
React Query Cache Integration
// hooks/useProducts.ts
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { fetchProducts, updateProduct } from '@/lib/api/products';
export function useProducts(page: number) {
return useQuery({
queryKey: ['products', page],
queryFn: () => fetchProducts(page),
staleTime: 5 * 60 * 1000, // 5 minutes
});
}
export function useUpdateProduct() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({ id, data }: { id: string; data: any }) =>
updateProduct(id, data),
onSuccess: (updatedProduct) => {
// Update the product in the cache
queryClient.setQueryData(
['product', updatedProduct.data.id],
updatedProduct
);
// Invalidate products list to refetch
queryClient.invalidateQueries({ queryKey: ['products'] });
},
});
}
Optimistic Updates: Building Instant UIs
Optimistic updates provide immediate feedback while the server processes the request.
Optimistic Updates with useOptimistic
// app/comments/CommentList.tsx
'use client';
import { useOptimistic } from 'react';
import { addComment, deleteComment } from '@/app/actions/comments';
type Comment = {
id: string;
text: string;
author: string;
pending?: boolean;
};
export function CommentList({
comments: initialComments,
postId
}: {
comments: Comment[];
postId: string;
}) {
const [optimisticComments, addOptimisticComment] = useOptimistic(
initialComments,
(state, newComment: Comment) => [...state, newComment]
);
async function handleAddComment(formData: FormData) {
const text = formData.get('text') as string;
// Immediately show the comment
addOptimisticComment({
id: crypto.randomUUID(),
text,
author: 'You',
pending: true,
});
// Then submit to server
await addComment(postId, text);
}
return (
<div>
<form action={handleAddComment}>
<textarea name="text" required />
<button type="submit">Add Comment</button>
</form>
<div className="comments">
{optimisticComments.map(comment => (
<div
key={comment.id}
className={comment.pending ? 'pending' : ''}
>
<strong>{comment.author}</strong>
<p>{comment.text}</p>
</div>
))}
</div>
</div>
);
}
Optimistic Updates with React Query
// hooks/useComments.ts
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { addComment } from '@/lib/api/comments';
export function useAddComment(postId: string) {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (text: string) => addComment(postId, text),
onMutate: async (text) => {
// Cancel outgoing refetches
await queryClient.cancelQueries({ queryKey: ['comments', postId] });
// Snapshot previous value
const previousComments = queryClient.getQueryData(['comments', postId]);
// Optimistically update
queryClient.setQueryData(['comments', postId], (old: any) => [
...old,
{
id: crypto.randomUUID(),
text,
author: 'You',
pending: true,
},
]);
return { previousComments };
},
onError: (err, newComment, context) => {
// Rollback on error
queryClient.setQueryData(
['comments', postId],
context?.previousComments
);
},
onSettled: () => {
// Refetch after mutation
queryClient.invalidateQueries({ queryKey: ['comments', postId] });
},
});
}
Best Practices Section
✅ Dos
Use Server Actions for form submissions - They work without JavaScript and provide excellent progressive enhancement:
// ✅ Good - Server Action
'use server';
export async function createPost(formData: FormData) {
const title = formData.get('title');
await db.posts.create({ data: { title } });
revalidatePath('/posts');
}
Implement proper error handling - Always handle errors gracefully and provide user feedback:
// ✅ Good - Comprehensive error handling
export async function createPost(formData: FormData) {
try {
// Validation
if (!title || title.length < 3) {
return { error: 'Title too short' };
}
// Database operation
await db.posts.create({ data: { title } });
revalidatePath('/posts');
return { success: true };
} catch (error) {
console.error('Create post error:', error);
return { error: 'Failed to create post' };
}
}
Use optimistic updates for better UX - Show immediate feedback while the server processes:
// ✅ Good - Optimistic update
const [optimisticLikes, addOptimisticLike] = useOptimistic(
likes,
(state, amount: number) => state + amount
);
Leverage React Query for complex client state - Use it for polling, infinite scroll, and complex caching:
// ✅ Good - React Query for real-time data
const { data } = useQuery({
queryKey: ['notifications'],
queryFn: fetchNotifications,
refetchInterval: 30000, // Poll every 30 seconds
});
Cache strategically - Use appropriate caching strategies for different data types:
// ✅ Good - Strategic caching
// Static content - cache forever
fetch('/api/config', { cache: 'force-cache' });
// Dynamic content - revalidate periodically
fetch('/api/posts', { next: { revalidate: 60 } });
// User-specific - never cache
fetch('/api/user', { cache: 'no-store' });
❌ Don'ts
Don't use API routes when Server Actions suffice - Server Actions are simpler for mutations:
// ❌ Bad - Unnecessary API route
// /api/posts/route.ts
export async function POST(req) {
const body = await req.json();
await db.posts.create({ data: body });
return Response.json({ success: true });
}
// ✅ Good - Server Action
'use server';
export async function createPost(formData: FormData) {
await db.posts.create({ data: { title: formData.get('title') } });
revalidatePath('/posts');
}
Don't forget to revalidate after mutations - Always revalidate affected paths or tags:
// ❌ Bad - No revalidation
export async function updatePost(id, data) {
await db.posts.update({ where: { id }, data });
// Users won't see changes!
}
// ✅ Good - Revalidate
export async function updatePost(id, data) {
await db.posts.update({ where: { id }, data });
revalidatePath('/posts');
revalidateTag(`post-${id}`);
}
Don't expose sensitive data in API routes - Always validate authentication and authorization:
// ❌ Bad - No auth check
export async function GET() {
const users = await db.users.findMany();
return Response.json(users); // Exposes all user data!
}
// ✅ Good - Auth check
export async function GET(request) {
const user = await verifyAuth(request);
if (!user || user.role !== 'admin') {
return Response.json({ error: 'Unauthorized' }, { status: 401 });
}
const users = await db.users.findMany();
return Response.json(users);
}
Don't over-fetch data - Fetch only what you need:
// ❌ Bad - Fetches everything
const user = await db.users.findUnique({
where: { id },
include: { posts: true, comments: true, likes: true }
});
// ✅ Good - Selective fetching
const user = await db.users.findUnique({
where: { id },
select: { id: true, name: true, email: true }
});
Common Pitfalls
Pitfall 1: Forgetting 'use server' directive
// ❌ Bad - Missing directive
export async function createPost(formData: FormData) {
await db.posts.create({ data: { title: formData.get('title') } });
}
// ✅ Good - Directive included
'use server';
export async function createPost(formData: FormData) {
await db.posts.create({ data: { title: formData.get('title') } });
}
Pitfall 2: Not handling loading states
// ❌ Bad - No loading state
const { data } = useQuery({ queryKey: ['posts'], queryFn: fetchPosts });
return <div>{data.map(post => ...)}</div>; // Crashes before data loads
// ✅ Good - Proper loading handling
const { data, isLoading } = useQuery({
queryKey: ['posts'],
queryFn: fetchPosts
});
if (isLoading) return <LoadingSpinner />;
return <div>{data.map(post => ...)}</div>;
Pitfall 3: Incorrect query invalidation
// ❌ Bad - Too broad invalidation
queryClient.invalidateQueries(); // Refetches everything!
// ✅ Good - Targeted invalidation
queryClient.invalidateQueries({ queryKey: ['posts'] });
Conclusion
Mastering data fetching in Next.js 15 means understanding when to use each pattern: Server Actions for forms and mutations, Server Components for initial data loading, Route Handlers for external APIs, and React Query for complex client-side state management. Each approach has specific strengths, and combining them effectively creates applications that feel instant while maintaining data consistency.
The key takeaways: use Server Actions for progressive enhancement and seamless form handling, implement optimistic updates for immediate user feedback, leverage appropriate caching strategies for different data types, use React Query for complex client-side scenarios like polling and infinite scroll, and always handle errors gracefully with proper user feedback. These patterns form the foundation of modern, performant Next.js applications.
Remember that data fetching isn't just about getting data from point A to point B—it's about creating experiences that feel fast, reliable, and intuitive. Start with Server Components and Server Actions for simplicity, then add React Query when you need advanced client-side features. The architecture you build today determines how maintainable and scalable your application will be tomorrow.
Resources
GitHub Repository
- Next.js Data Fetching Examples - Official examples
Official Documentation
- Next.js Data Fetching - Comprehensive guide
- Server Actions Documentation - Complete reference
- Route Handlers Guide - API endpoints
- TanStack Query Documentation - React Query complete guide
Related Articles
- "Next.js 15 App Router: Complete Guide to Server and Client Components" - Component architecture fundamentals
- "Next.js Caching Deep Dive: Request Memoization, Data Cache, and Full Route Cache" - Advanced caching strategies
- "Building Real-Time Features in Next.js with Server Actions and WebSockets" - Real-time data patterns
Meta Description: Master Next.js 15 data fetching with Server Actions, API routes, and React Query. Complete guide to CRUD operations, optimistic updates, and caching strategies.
Tags: #nextjs #datafetching #serveractions #reactquery #typescript #crud #webdev
Top comments (0)