---
title: "Next.js 14 App Router: những chỗ dễ vấp khi chạy thật (Server Action, cache, deploy)"
url: https://phatdev.net/vi/blog/nextjs-app-router-server-actions
lang: vi
updated: 2026-10-21
alternate:
  en: https://phatdev.net/en/blog/nextjs-app-router-server-actions
markdown: https://phatdev.net/vi/blog/nextjs-app-router-server-actions.md
---

# Next.js 14 App Router: những chỗ dễ vấp khi chạy thật (Server Action, cache, deploy)

> Vì sao Server Action là cánh cửa công khai, vì sao trang tĩnh hiện dữ liệu cũ, và vì sao deploy xong trang trắng báo lỗi lạ. Đã kiểm bằng app Next.js 14.2.35 build và chạy thật.

Chuyên mục: Giao diện | Đăng: 2026-10-19 | Cấp độ: Trung cấp | 8 phút đọc

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

Bạn deploy bản mới của một web Next.js. Mở ra thì trang trắng, chỉ có dòng "Application error: a client-side exception has occurred". Code không đổi gì ở chỗ đó. Hoặc một chuyện khác: bạn sửa giá trên trang "tĩnh", lưu xong, vài giờ sau khách vẫn thấy giá cũ.

Cả hai chuyện đều có nguyên nhân rõ ràng, nhưng tài liệu của Next.js 14 (App Router) rải chúng ra nhiều trang. Mình chạy vài ứng dụng Next.js 14 bằng PM2 phía sau Nginx, và bài này gom những chỗ hay vấp mà mình đã kiểm lại bằng cách dựng một app nhỏ, build thật, chạy thật, rồi bắn request vào.

![Hai đảo nổi: server ở bên trái giữ dữ liệu, trình duyệt ở bên phải giữ phần tương tác](https://phatdev.net/uploads/blog/nextjs-app-router-server-actions/server-client-1920.webp)

## Phần nào chạy ở đâu

Trong App Router, mỗi thành phần của trang mặc định chạy trên server (gọi là Server Component). Nó đọc được database, giữ được mật khẩu, và code của nó không bị gửi xuống trình duyệt. Muốn có nút bấm, ô nhập, thứ phản ứng với người dùng, bạn thêm dòng `"use client"` ở đầu file, và thành phần đó chạy cả trong trình duyệt (Client Component).

Quy tắc mình dùng: để server làm mặc định, chỉ đẩy `"use client"` xuống những mảnh nhỏ thật sự cần tương tác, như một form hay một nút. Dòng `"use client"` là một ranh giới: mọi thứ file đó import cũng bị kéo xuống trình duyệt. Dữ liệu truyền từ server sang client phải là loại gói được (chuỗi, số, mảng, object thường), không truyền được hàm thường.

## Server Action: một cánh cửa công khai

Server Action là hàm chạy trên server mà bạn gọi từ form như gọi hàm thường. Nghe tiện, nhưng có một điều cần nhớ kỹ: Next.js biến mỗi action thành một địa chỉ nhận yêu cầu POST, và ai biết địa chỉ đó đều gửi tới được, kể cả khi bạn không hề vẽ nút bấm cho họ. Ẩn nút trên giao diện không phải là kiểm soát quyền.

Nên mỗi action phải tự bảo vệ mình, như một cánh cửa có người gác. Ba việc làm ở đầu hàm:

1. Kiểm tra người gọi là ai, có quyền không. Với thao tác sửa hoặc xoá, kiểm cả "bản ghi này có phải của họ không". Thiếu bước sau thì người dùng A xoá được dữ liệu của B bằng cách đổi con số `id`.
2. Kiểm tra dữ liệu gửi lên. Mọi thứ từ form đều đến từ ngoài, kể cả khi form của bạn có `required` hay `maxLength`.
3. Chỉ trả về những gì trình duyệt được phép thấy, vì giá trị trả về sẽ gửi xuống đó.

Về chuyện giả mạo yêu cầu từ trang khác (CSRF), Next.js tự so header `Origin` với `Host` của yêu cầu và từ chối khi lệch. Mình bắn thử bằng `curl` vào action đang chạy trên Next.js 14.2.35: không có cookie đăng nhập thì ra "Chưa đăng nhập"; đã đăng nhập nhưng tiêu đề chỉ 2 ký tự thì ra lỗi kiểm tra dữ liệu; hợp lệ thì thêm được, và tải lại `/notes` thấy ghi chú mới. Khi gửi kèm `Origin: https://evil.example`, server ném `Invalid Server Actions request.` và trả 500, log ghi rõ hai địa chỉ không khớp.

Hai điều cần nhớ. Một là khi chạy sau Nginx hoặc Cloudflare, phải chuyển đúng header `Host` hoặc `X-Forwarded-Host`, nếu không action của chính bạn bị chặn nhầm. Có thể khai báo tên miền được phép ở `experimental.serverActions.allowedOrigins` trong `next.config`. Đừng tắt việc kiểm tra. Hai là yêu cầu không có `Origin` chỉ bị cảnh báo chứ vẫn chạy. Trình duyệt luôn gửi `Origin` cho POST nên đây không phải lỗ hổng CSRF, nhưng nó nhắc rằng chốt chặn thật sự là kiểm quyền trong action.

## Vì sao trang "tĩnh" hiện dữ liệu cũ

![Các lớp cache xếp chồng, mũi tên làm mới đi xuyên qua để thay bản cũ](https://phatdev.net/uploads/blog/nextjs-app-router-server-actions/cache-layers-1920.webp)

Next.js 14 có nhiều lớp ghi nhớ. Bốn cái bạn sẽ chạm tới hằng ngày:

Trang tĩnh. Trang không dùng thông tin riêng của từng yêu cầu được dựng sẵn lúc build và giữ nguyên. Trong bảng kết quả của `next build`, ký hiệu `○` là tĩnh, `ƒ` là dựng theo yêu cầu. Mình tạo trang `/time` in giờ hiện tại: gọi hai lần cách nhau 1 giây ra cùng một giá trị, header trả `s-maxage=31536000`. Giờ đó bị đóng băng từ lúc build.

`fetch` trong Server Component. Ở Next.js 14, `fetch` mặc định được ghi nhớ. Mình dựng một API đếm số lần bị gọi. Trang dùng `next: { revalidate: 3600 }` luôn thấy `call=1` qua nhiều lần tải, còn trang dùng `cache: "no-store"` thấy số tăng mỗi lần. Từ Next.js 15 mặc định này đổi, nên đừng áp kiến thức giữa các phiên bản.

`revalidatePath`. Sau khi action thêm ghi chú và gọi `revalidatePath("/notes")`, lần tải kế tiếp của `/notes` thấy ngay ghi chú mới dù trang này là `○`. Trang `/time` không bị gọi nên vẫn giữ giá trị cũ. Đây là cách báo "dữ liệu này đã đổi, dựng lại đi".

Bộ nhớ trong trình duyệt (Router Cache). Theo tài liệu 14.2, khoảng 30 giây cho trang động và 5 phút cho trang tĩnh, nên bấm quay lại có thể thấy bản cũ một lúc. Phần này mình chỉ dẫn lại từ tài liệu, chưa tự đo.

## Hai cái bẫy mình gặp nhiều nhất

**Một lời gọi nhỏ làm cả trang thành động.** Chỉ cần gọi `headers()` hay `cookies()` ở đâu đó trong cây hiển thị của một trang, trang đó thành `ƒ` và không còn tĩnh. Nguy hiểm hơn là gọi nó trong `layout.tsx` gốc để đọc một header: mình thử và cả 7 trang của app mẫu chuyển thành `ƒ`, kể cả trang chủ trống. Nếu chỉ vài trang cần đăng nhập, hãy gom chúng vào một nhóm đường dẫn có layout riêng, đừng đặt ở layout gốc.

**Deploy xong là báo lỗi lạ.** Đây là lỗi mình thấy nhiều nhất khi tự deploy bằng PM2. Process đang chạy là bản cũ, trong khi thư mục `.next` trên đĩa đã là bản mới. Trình duyệt tải trang cũ rồi đi xin file JavaScript theo tên cũ, và server trả về HTML lỗi thay vì JavaScript. Trang trắng.

Mình tái hiện đúng tình huống: chạy `next start`, ghi lại địa chỉ file JS của một trang, sửa một component, chạy `next build` mà không restart. Kết quả: `/notes` vẫn trả 200 và vẫn trỏ tới file JS cũ, nhưng xin file cũ đó trả về 400 với `Content-Type: text/html`. Sau khi tắt process và chạy lại `next start`, trang trả file mới (200) và file cũ trả 404.

Quy trình của mình: build xong thì restart đúng process (`pm2 restart <tên> --update-env`), rồi kiểm bằng cách tải trang và chắc rằng các file `_next/static/*.js` trả 200 đúng loại, đừng chỉ nhìn trang trả 200. Nếu có nhiều bản chạy song song, hoặc người dùng đang mở tab cũ lúc bạn deploy, đặt thêm biến `NEXT_SERVER_ACTIONS_ENCRYPTION_KEY` để mọi bản dùng chung một khoá mã hoá cho action. Khi tab cũ gọi một action đã bị đổi, server trả `Failed to find Server Action ... older or newer deployment`. Mình gửi thử một ID giả và thấy đúng thông báo này. Bắt lỗi ở client và gợi ý người dùng tải lại trang là đủ.

## Cho bạn nào viết code

Mình dựng app mẫu Next.js 14.2.35 với React 18 và `zod`, chạy `tsc --noEmit` sạch, `next build` thành công và kiểm lại các kết quả ở trên. Đây là Server Component và Server Action mình thử:

```tsx
// app/notes/page.tsx (Server Component, không có "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, chỉ phần form được gửi xuống trình duyệt */}
    </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) quyền
  if (!session) return { ok: false, message: "Chưa đăng nhập" };

  const parsed = CreateNote.safeParse({ title: formData.get("title") }); // 2) dữ liệu
  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) làm mới
  return { ok: true, message: "Đã thêm" };
}
```

Lưu ý về phiên bản (10/2026): nhánh 14.x dừng ở 14.2.35, còn bản mới nhất đã là dòng 16. Từ Next.js 15, `headers()` và `cookies()` trở thành bất đồng bộ, nên `getSession()` ở trên sẽ cần `await`, và hook `useFormState` đổi thành `useActionState`. Bài này viết cho 14.2.x.

## Nên làm gì tiếp theo

Mở bảng kết quả của `next build` và xem trang nào bất ngờ là `ƒ`. Nếu có, tìm xem ai gọi `headers()` hay `cookies()`. Rồi kiểm mỗi Server Action đã có đủ ba việc ở đầu hàm chưa. Và sau mỗi lần deploy, restart đúng process rồi kiểm các file `_next/static` trả 200. Nếu ứng dụng của bạn cần ghi nhớ dữ liệu ở tầng ngoài Next.js, bài [Cho web nhanh hơn bằng Redis: nhớ tạm kết quả để database đỡ quá tải](https://phatdev.net/vi/blog/redis-cache-aside-api) đi tiếp phần đó, và [Dùng chung một kết nối cho cả ứng dụng: Singleton đúng cách](https://phatdev.net/vi/blog/singleton-pattern-typescript-php) giải thích vì sao kết nối database nên dùng chung một bản khi `next dev` nạp lại file.

## Nguồn tham khảo

- [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)

## Bài liên quan

- [Dùng chung một kết nối cho cả ứng dụng: Singleton đúng cách](https://phatdev.net/vi/blog/singleton-pattern-typescript-php.md)
- [Cho web nhanh hơn bằng Redis: nhớ tạm kết quả để database đỡ quá tải](https://phatdev.net/vi/blog/redis-cache-aside-api.md)
- [Chuẩn bị gì trước khi thuê người làm web hay phần mềm (kèm mẫu brief để chép)](https://phatdev.net/vi/blog/brief-before-hiring-developer.md)
