How to Solve Caching Issues When Mixing Next.js Server Actions and Client-side Fetch

ํ…Œ
ํ…Œ์ŠคํŠธ์ €์žAuthor
5 min read

After the introduction of the App Router in Next.js 13 and the enhancement of Server Actions as an official feature in version 14, more projects have started adopting a server-centric architecture. However, during this transition, many developers commonly face one specific issue: caching problems that arise when Server Actions and client-side fetch requests are used together.

Next.js is a framework that strongly enforces โ€œserver-centric rendering and cachingโ€ by default. However, in real-world applications where Server Actions and the fetch API are mixed, this structure often leads to unexpected bugs. In this post, weโ€™ll explore why these issues occur and how to solve them, based on actual code examples.


1. Why Mixing Server Actions and Fetch Leads to Issues

Next.jsโ€™s fetch behaves as follows when run on the server:

  • Identical fetch requests are automatically cached based on the same input.
  • The response can be shared depending on the build time, revalidation time, or route caching policy.
  • Server Components actively utilize server-side caching for fetch.

On the other hand, Server Actions have different characteristics:

  • They do not automatically trigger revalidation upon execution.
  • The UI does not update automatically after the action runs.
  • They can be called from Client Components, not just Server Components.

As a result, when these two are combined, typical issues occur easily:


2. Common Problem Scenarios

Issue 1. Server Action updates data, but the UI stays the same

Example:

// server action
export async function updateName(id: string, name: string) {
  await prisma.user.update({ where: { id }, data: { name } });
}

Data fetching in a server component:

const user = await fetch("https://api.example.com/user/1").then(res => res.json());

Even after updateName runs, fetch returns the previously cached data. In other words, the database is updated, but Next.js remains unaware of the change.


Issue 2. Client-side fetch gets fresh data, but server component still shows cached data

From the client:

const res = await fetch("/api/user/1", { cache: "no-store" });

This fetch always retrieves fresh data. However, the server component still displays cached data.


Issue 3. Cache inconsistencies due to misunderstanding of default fetch behavior

Default fetch policy in the App Router:

  • Server-side: cache: "force-cache" (default)
  • Client-side: cache: "no-store" (default)

Thus, calling the same URL from the server and client can return different results.


3. Solution Strategies

There are several ways to address these issues. Here are four common strategies:

1) Explicitly control caching in fetch

This is the simplest approach. Instead of relying on the default fetch behavior in server components, specify caching explicitly:

await fetch(url, { cache: "no-store" })

Or:

await fetch(url, { next: { revalidate: 0 } })

Both approaches disable caching. However, using no-store for every request can significantly degrade performance.


2) Use revalidatePath or revalidateTag in Server Actions

When data is mutated via Server Actions, you must explicitly trigger revalidation so the UI reflects the latest state.

Using revalidatePath

import { revalidatePath } from "next/cache";

export async function updateName(id: string, name: string) {
  await prisma.user.update({ where: { id }, data: { name } });
  revalidatePath("/users"); // Re-render specific path
}

Using revalidateTag

Add tags to fetch requests and use revalidateTag:

// Server component
await fetch(url, { next: { tags: ["users"] } });

// Server action
revalidateTag("users");

This method offers the most reliable way to update the UI.


3) Separate concerns: Route Handlers (APIs) for reads, Server Actions for mutations

A common problematic structure:

  • Read data: Server component fetch
  • Mutate data: Server Action
  • Re-fetch: Client-side fetch

This setup often leads to cache inconsistencies.

The solution: unify both reads and writes around Route Handlers (APIs):

// GET /api/users
export async function GET() {
  const users = await prisma.user.findMany();
  return Response.json(users);
}

And fetch like this:

fetch("/api/users", { cache: "no-store" });

Limit Server Actions to mutation roles only. This reduces cache conflicts significantly.


4) Avoid fetch inside Server Actions when possible

Many developers attempt to reuse fetch within Server Actions:

await fetch("/api/user/1", { cache: "force-cache" });

But Server Actions can clash with Next.jsโ€™s internal fetch caching and cause redundant work.

Itโ€™s safer to access the data source (e.g., DB or Prisma) directly within the Server Action.


Hereโ€™s a reliable structure using Next.js 14:

1) Fetching data in a server component

// app/users/page.tsx
export default async function UsersPage() {
  const users = await fetch("https://example.com/api/users", {next: { tags: ["users"] },
  }).then((res) => res.json());

  return <UsersList users={users} />;
}

2) Mutating data in Server Action with revalidateTag

"use server";

import { prisma } from "@/lib/prisma";
import { revalidateTag } from "next/cache";

export async function updateUserName(id: string, name: string) {
  await prisma.user.update({ where: { id }, data: { name } });
  revalidateTag("users");
}

3) Calling Server Action from client

"use client";
import { updateUserName } from "./actions";

export default function EditForm() {
  async function onSubmit() {await updateUserName("1", "New Name");
  }

  return <button onClick={onSubmit}>Update</button>;
}

This structure ensures the UI stays up to date without cache conflicts.


5. Conclusion

Server Actions and fetch in Next.js are powerful features, but they operate under different caching strategies, leading to issues when used together.

Key takeaways:

  1. Server-side fetch is cached by default.
  2. Server Actions do not automatically trigger revalidation.
  3. You must use revalidatePath or revalidateTag to update the UI after a Server Action.
  4. Explicitly set cache options (no-store, revalidate) in fetch calls.
  5. Separate responsibilities between Route Handlers and Server Actions to avoid cache conflicts.

Understanding these principles will help you design stable and predictable data flows in App Router-based Next.js applications.

How to Solve Caching Issues When Mixing Next.js Server Actions and Client-side Fetch