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.

Phát LâmPublished Updated 8 min readIntermediate
Next.js 14 App Router: Where It Bites in Production (Server Actions, Cache, Deploys)
On this page
  1. What runs where
  2. Server Actions are a public door
  3. Why a "static" page shows old data
  4. The two traps I meet most
  5. For readers who write code
  6. What to do next

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, a server island with racks and a database linked by a light beam to a browser island with a toggle and a button
The server holds the data, the browser holds the interactivity, joined by a thin bridge.

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

Three stacked cache layers with a violet refresh arrow cutting through them
Cache layers and revalidation: refreshing from the bottom up.

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:

// 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>
  );
}
// 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 picks up from there, and One Shared Connection for the Whole App: Using Singleton Properly explains why a database connection should be shared when next dev reloads files.

Next.jsReactTypeScriptBảo mật full-stacknextjsapp-routerserver-actionsserver-componentszodrevalidatepathcachedeploy

References

  1. Next.js 14 docs: Server Actions and Mutations
  2. Next.js 14 docs: Caching in Next.js
  3. Next.js 14 docs: Server Components
  4. Next.js 14 docs: Deploying (self-hosting)
  5. Zod