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.

Phát LâmĐăng Cập nhật 8 phút đọcTrung cấp
Next.js 14 App Router: những chỗ dễ vấp khi chạy thật (Server Action, cache, deploy)
Mục lục
  1. Phần nào chạy ở đâu
  2. Server Action: một cánh cửa công khai
  3. Vì sao trang "tĩnh" hiện dữ liệu cũ
  4. Hai cái bẫy mình gặp nhiều nhất
  5. Cho bạn nào viết code
  6. Nên làm gì tiếp theo

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, đảo server có máy chủ và cơ sở dữ liệu nối bằng tia sáng tới đảo trình duyệt có công tắc và nút
Server giữ dữ liệu, trình duyệt giữ phần tương tác, hai bên nối bằng một cầu mỏng.

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ũ

Ba tấm lớp cache xếp chồng với mũi tên tím làm mới xuyên qua
Các tầng cache và revalidate: làm mới từ dưới lên.

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ử:

// 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>
  );
}
// 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 đi tiếp phần đó, và Dùng chung một kết nối cho cả ứng dụng: Singleton đúng cách 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.

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

Nguồn tham khảo

  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