DEV Community

Cover image for Next.js Data Fetching Patterns: Server Actions, API Routes, and React Query
jordan wilfry
jordan wilfry

Posted on

Next.js Data Fetching Patterns: Server Actions, API Routes, and React Query

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:

  1. Server Components - Direct database queries and API calls on the server
  2. Server Actions - Server-side mutations called from Client Components
  3. Route Handlers - Traditional API endpoints for REST APIs
  4. 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' };
  }
}
Enter fullscreen mode Exit fullscreen mode
// 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>
  );
}
Enter fullscreen mode Exit fullscreen mode
// 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>
  );
}
Enter fullscreen mode Exit fullscreen mode

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'] },
    };
  }
}
Enter fullscreen mode Exit fullscreen mode
// 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>
  );
}
Enter fullscreen mode Exit fullscreen mode

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');
}
Enter fullscreen mode Exit fullscreen mode
// 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>
  );
}
Enter fullscreen mode Exit fullscreen mode

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>
  );
}
Enter fullscreen mode Exit fullscreen mode
// 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>
  );
}
Enter fullscreen mode Exit fullscreen mode

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 }
    );
  }
}
Enter fullscreen mode Exit fullscreen mode
// 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 }
    );
  }
}
Enter fullscreen mode Exit fullscreen mode

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;
  }
}
Enter fullscreen mode Exit fullscreen mode
// 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,
  });
}
Enter fullscreen mode Exit fullscreen mode

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>
  );
}
Enter fullscreen mode Exit fullscreen mode
// app/layout.tsx
import { Providers } from './providers';

export default function RootLayout({ children }: { children: ReactNode }) {
  return (
    <html lang="en">
      <body>
        <Providers>
          {children}
        </Providers>
      </body>
    </html>
  );
}
Enter fullscreen mode Exit fullscreen mode

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();
}
Enter fullscreen mode Exit fullscreen mode
// 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>
  );
}
Enter fullscreen mode Exit fullscreen mode

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();
}
Enter fullscreen mode Exit fullscreen mode
// 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>
  );
}
Enter fullscreen mode Exit fullscreen mode

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>
  );
}
Enter fullscreen mode Exit fullscreen mode

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>
  );
}
Enter fullscreen mode Exit fullscreen mode

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>
  );
}
Enter fullscreen mode Exit fullscreen mode

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>
  );
}
Enter fullscreen mode Exit fullscreen mode

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();
}
Enter fullscreen mode Exit fullscreen mode

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();
}
Enter fullscreen mode Exit fullscreen mode
// 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');
}
Enter fullscreen mode Exit fullscreen mode

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;
}
Enter fullscreen mode Exit fullscreen mode

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'] });
    },
  });
}
Enter fullscreen mode Exit fullscreen mode

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>
  );
}
Enter fullscreen mode Exit fullscreen mode

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] });
    },
  });
}
Enter fullscreen mode Exit fullscreen mode

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');
}
Enter fullscreen mode Exit fullscreen mode

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' };
  }
}
Enter fullscreen mode Exit fullscreen mode

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
);
Enter fullscreen mode Exit fullscreen mode

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
});
Enter fullscreen mode Exit fullscreen mode

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' });
Enter fullscreen mode Exit fullscreen mode

❌ 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');
}
Enter fullscreen mode Exit fullscreen mode

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}`);
}
Enter fullscreen mode Exit fullscreen mode

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);
}
Enter fullscreen mode Exit fullscreen mode

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 }
});
Enter fullscreen mode Exit fullscreen mode

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') } });
}
Enter fullscreen mode Exit fullscreen mode

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>;
Enter fullscreen mode Exit fullscreen mode

Pitfall 3: Incorrect query invalidation

// ❌ Bad - Too broad invalidation
queryClient.invalidateQueries(); // Refetches everything!

// ✅ Good - Targeted invalidation
queryClient.invalidateQueries({ queryKey: ['posts'] });
Enter fullscreen mode Exit fullscreen mode

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

Official Documentation

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)