---
title: "Next.js 14 App Router: Where It Bites in Production (Server Actions, Cache, Deploys)"
url: https://phatdev.net/en/blog/nextjs-app-router-server-actions
lang: en
updated: 2026-10-21
alternate:
  vi: https://phatdev.net/vi/blog/nextjs-app-router-server-actions
markdown: https://phatdev.net/en/blog/nextjs-app-router-server-actions.md
---

# Next.js 14 App Router: Where It Bites in Production (Server Actions, Cache, Deploys)

> Why a Server Action is a public door, why a static page shows old data, and why a fresh deploy gives a blank page. Checked on a real Next.js 14.2.35 build.

Categories: Frontend | Published: 2026-10-19 | Level: Intermediate | 8 min read

Tags: nextjs, app-router, server-actions, server-components, zod, revalidatepath, cache, deploy

You deploy a new version of a Next.js site. You open it and get a blank page with the line "Application error: a client-side exception has occurred". Nothing changed in the code near that spot. Or something else: you fix a price on a "static" page, save, and hours later customers still see the old one.

Both have clear causes, but the Next.js 14 (App Router) docs spread them across many pages. I run a few Next.js 14 apps with PM2 behind Nginx, and this post collects the places that trip people up, each one rechecked by building a small app, running the real build, and sending requests at it.

![Two floating islands: the server on the left holds data, the browser on the right holds interaction](https://phatdev.net/uploads/blog/nextjs-app-router-server-actions/server-client-1920.webp)

## What runs where

In the App Router, every piece of a page runs on the server by default (a Server Component). It can read the database, hold secrets, and its code is never sent to the browser. When you want buttons, inputs, anything that reacts to the user, you add `"use client"` at the top of the file and that piece also runs in the browser (a Client Component).

My rule: let the server be the default and push `"use client"` down into the small pieces that truly need interaction, such as one form or one button. The `"use client"` line is a boundary: everything that file imports is pulled into the browser too. Data passed from server to client must be a packable kind (strings, numbers, arrays, plain objects), not an ordinary function.

## Server Actions are a public door

A Server Action is a function that runs on the server and that you call from a form like an ordinary function. It sounds convenient, but remember this: Next.js turns each action into an address that accepts POST requests, and anyone who knows the address can send to it, even if you never drew a button for them. Hiding a button is not access control.

So every action has to guard itself, like a door with a guard. Three jobs at the top of the function:

1. Check who is calling and whether they are allowed. For edit or delete, also check "does this record belong to them". Without that second check, user A can delete user B's data just by changing the `id` number.
2. Validate what was sent. Everything from a form comes from outside, even if your form has `required` or `maxLength`.
3. Return only what the browser may see, because the return value is sent down to it.

As for forged requests from another site (CSRF), Next.js compares the `Origin` header with the request's `Host` and refuses when they differ. I fired `curl` at a running action on Next.js 14.2.35: with no login cookie it said "Chưa đăng nhập" (not signed in); signed in but with a 2-character title it returned the validation error; with a valid title it added the note, and reloading `/notes` showed it. With `Origin: https://evil.example` attached, the server threw `Invalid Server Actions request.` and returned 500, and the log said which two addresses did not match.

Two things to remember. First, behind Nginx or Cloudflare you must forward the right `Host` or `X-Forwarded-Host`, or your own actions get blocked by mistake. You can list allowed domains in `experimental.serverActions.allowedOrigins` in `next.config`. Do not switch the check off. Second, a request without an `Origin` only produces a warning and still runs. Browsers always send `Origin` on a POST, so this is not a CSRF hole, but it reminds you that the real guard is the permission check inside the action.

## Why a "static" page shows old data

![Stacked cache layers, with a refresh arrow cutting through to replace the old copy](https://phatdev.net/uploads/blog/nextjs-app-router-server-actions/cache-layers-1920.webp)

Next.js 14 has several layers of remembering. Four you will meet every day:

Static pages. A page that uses nothing specific to each request is built at build time and kept as is. In the `next build` result table, `○` means static and `ƒ` means built on demand. I made a `/time` page that prints the current time: two calls one second apart returned the same value, and the header said `s-maxage=31536000`. That time was frozen at build.

`fetch` in a Server Component. In Next.js 14, `fetch` is remembered by default. I built a small API that counts how many times it was called. A page using `next: { revalidate: 3600 }` kept showing `call=1` across many loads, while a page using `cache: "no-store"` showed the number rising each time. From Next.js 15 this default changed, so do not carry knowledge across versions.

`revalidatePath`. After the action adds a note and calls `revalidatePath("/notes")`, the next load of `/notes` shows the new note at once, even though that page is `○`. The `/time` page was not called, so it kept its old value. This is how you say "this data changed, rebuild it".

The browser's own memory (Router Cache). According to the 14.2 docs it lasts about 30 seconds for dynamic pages and 5 minutes for static ones, so going back may show an old copy for a while. For this one I am only repeating the docs, I did not measure it.

## The two traps I meet most

**One small call turns the whole page dynamic.** Calling `headers()` or `cookies()` anywhere in a page's render tree makes that page `ƒ`, no longer static. Worse is calling it in the root `layout.tsx` to read a single header: I tried it and all 7 routes of the sample app turned into `ƒ`, even the empty home page. If only a few pages need login, group them under a route group with its own layout instead of putting it in the root layout.

**Strange errors right after a deploy.** This is the one I see most when deploying with PM2. The running process is the old build, while the `.next` folder on disk is already the new one. The browser loads the old page, asks for JavaScript files by their old names, and the server answers with an error page in HTML instead of JavaScript. Blank page.

I reproduced exactly that: ran `next start`, noted the address of a page's JS file, edited a component, ran `next build` without restarting. Result: `/notes` still returned 200 and still pointed at the old JS file, but asking for that old file returned 400 with `Content-Type: text/html`. After stopping the process and running `next start` again, the page returned the new file (200) and the old one returned 404.

My routine: after the build, restart the right process (`pm2 restart <name> --update-env`), then check by loading the page and making sure the `_next/static/*.js` files return 200 with the right type, not just that the page itself returns 200. If several copies run side by side, or users have an old tab open when you deploy, also set `NEXT_SERVER_ACTIONS_ENCRYPTION_KEY` so all copies share one encryption key for actions. When an old tab calls an action that changed, the server returns `Failed to find Server Action ... older or newer deployment`. I sent a fake ID and saw exactly that message. Catching the error in the client and suggesting a page reload is enough.

## For readers who write code

I built a sample app on Next.js 14.2.35 with React 18 and `zod`, ran `tsc --noEmit` clean, got a successful `next build`, and rechecked the results above. This is the Server Component and Server Action I tested:

```tsx
// app/notes/page.tsx (Server Component, no "use client")
import { notes } from "@/lib/store";
import { NoteForm } from "./NoteForm";

export default function NotesPage() {
  return (
    <main>
      <h1>Ghi chú</h1>
      <ul>{notes.map((n) => <li key={n.id}>{n.title} ({n.owner})</li>)}</ul>
      <NoteForm />   {/* Client Component, only the form is sent to the browser */}
    </main>
  );
}
```

```ts
// app/notes/actions.ts
"use server";

import { revalidatePath } from "next/cache";
import { z } from "zod";
import { getSession } from "@/lib/auth";
import { notes } from "@/lib/store";

const CreateNote = z.object({
  title: z.string().trim().min(3, "Tiêu đề tối thiểu 3 ký tự").max(80, "Tiêu đề tối đa 80 ký tự"),
});

export type FormState = { ok: boolean; message: string };

export async function createNote(_prev: FormState, formData: FormData): Promise<FormState> {
  const session = getSession();                       // 1) permission
  if (!session) return { ok: false, message: "Chưa đăng nhập" };

  const parsed = CreateNote.safeParse({ title: formData.get("title") }); // 2) data
  if (!parsed.success) return { ok: false, message: parsed.error.issues[0].message };

  notes.push({ id: notes.length + 1, title: parsed.data.title, owner: session.user });
  revalidatePath("/notes");                           // 3) refresh
  return { ok: true, message: "Đã thêm" };
}
```

A note on versions (10/2026): the 14.x line stopped at 14.2.35, and the newest release is already in the 16 line. From Next.js 15, `headers()` and `cookies()` became async, so `getSession()` above would need an `await`, and the `useFormState` hook became `useActionState`. This post is written for 14.2.x.

## What to do next

Open the `next build` result table and look for any page that is unexpectedly `ƒ`. If there is one, find who calls `headers()` or `cookies()`. Then check that every Server Action has all three jobs at the top. And after each deploy, restart the right process and check that the `_next/static` files return 200. If your app needs to remember data outside Next.js, [Make Your API Faster With Redis: Save Answers So the Database Gets a Break](https://phatdev.net/en/blog/redis-cache-aside-api) picks up from there, and [One Shared Connection for the Whole App: Using Singleton Properly](https://phatdev.net/en/blog/singleton-pattern-typescript-php) explains why a database connection should be shared when `next dev` reloads files.

## References

- [Next.js 14 docs: Server Actions and Mutations](https://nextjs.org/docs/14/app/building-your-application/data-fetching/server-actions-and-mutations)
- [Next.js 14 docs: Caching in Next.js](https://nextjs.org/docs/14/app/building-your-application/caching)
- [Next.js 14 docs: Server Components](https://nextjs.org/docs/14/app/building-your-application/rendering/server-components)
- [Next.js 14 docs: Deploying (self-hosting)](https://nextjs.org/docs/14/app/building-your-application/deploying)
- [Zod](https://zod.dev)

## Related posts

- [One Shared Connection for the Whole App: Using Singleton Properly](https://phatdev.net/en/blog/singleton-pattern-typescript-php.md)
- [Make Your API Faster With Redis: Save Answers So the Database Gets a Break](https://phatdev.net/en/blog/redis-cache-aside-api.md)
- [What to Prepare Before Hiring Someone to Build Your Website or Software (With a Brief Template)](https://phatdev.net/en/blog/brief-before-hiring-developer.md)
