Nâng cấp Next.js 15 lên 16 — Turbopack, static export và ba chỗ vấp

Ghi chép thật từ đợt nâng cấp site này lên Next.js 16 — vì sao vẫn giữ static export cho Nginx, Turbopack khắt khe hơn webpack ở đâu, và ba lỗi ngốn nhiều giờ nhất.

Next.js 16Tailwind CSSTurbopack
Nâng cấp website lên Next.js 16 với Turbopack và static export

Site này chạy Next.js Pages Router, nội dung viết bằng Markdown, build ra HTML tĩnh rồi để Nginx phục vụ thẳng thư mục out/. Không có Node server nào chạy nền. Đợt vừa rồi tôi đưa nó từ Next.js 15 lên Next.js 16 — bản có Turbopack làm bundler mặc định cho cả dev lẫn build.

Bài này ghi lại những gì thật sự tốn thời gian, không phải changelog. Nếu bạn đang cân nhắc nâng cấp một site tĩnh tương tự, ba mục dưới đây là chỗ nên đọc kỹ.

Vì sao vẫn giữ static export thay vì đổi sang server

Cám dỗ lớn nhất khi nâng version là "nhân tiện" đổi luôn kiến trúc: App Router, server components, ISR. Tôi bỏ qua tất cả, giữ nguyên output: 'export'.

Lý do nằm ở chỗ deploy: server hiện tại chỉ có Nginx trỏ vào một thư mục. Không PM2, không process cần restart, không cổng cần mở. Deploy = build ở máy local, commit thư mục out/, rồi git pull trên server. Muốn rollback thì git revert. Toàn bộ mô hình vận hành đó biến mất ngay khi cần một Node process chạy nền.

Đổi lại, tôi mất khả năng dùng ISR và middleware. Với một site nội dung tĩnh, đó là cái giá rẻ.

Một thay đổi cần biết: lệnh next export không còn tồn tại. Từ Next 13 trở đi cấu hình output: 'export' trong next.config.js đảm nhiệm việc này, và next build tự sinh ra out/. Script nào còn gọi next export sẽ fail ngay.

Turbopack khắt khe hơn webpack ở chỗ nào

Turbopack là bundler mặc định của Next 16. Nó nhanh hơn rõ rệt — dev server khởi động dưới một giây — nhưng nghiêm ngặt hơn webpack ở phần CSS. Hai lỗi dưới đây làm tôi mất buổi sáng.

@import phải đứng trước mọi rule khác

File globals.css của tôi có dòng @import 'prism.css' nằm giữa file, sau vài rule. Webpack im lặng cho qua. Turbopack fail build ngay, và nó đúng: CSS spec quy định @import phải đứng đầu.

css
/* Đúng: @import trước tiên, rồi mới tới phần còn lại */
@import 'prism.css';

@tailwind base;
@tailwind components;
@tailwind utilities;

Sửa mất ba giây. Tìm ra thì lâu hơn, vì thông báo lỗi không chỉ đúng dòng.

Comment CSS nuốt mất selector

Lỗi này mới thật sự khó chịu. Tailwind báo Unexpected '/' — không kèm tên file, không số dòng. Build fail, còn dev server thì chạy bình thường.

Thủ phạm là một comment tôi viết để giải thích hệ màu:

css
/* Component dùng class bg-omega-*/text-omega-* sẽ tự đổi theo theme */

Chuỗi */ nằm giữa câu đóng comment sớm. Phần còn lại (text-omega-* sẽ tự đổi theo theme */) rơi ra ngoài, trở thành selector rác. Tôi phải bisect từng file CSS bằng Tailwind CLI mới khoanh được vùng.

Bài học giữ lại: đừng bao giờ viết chuỗi */ bên trong comment CSS — kể cả khi đang mô tả tên class có dấu sao.

Light mode không cần viết dark: cho từng class

Phần này không liên quan tới Next 16, nhưng là thay đổi đáng giá nhất trong đợt làm.

Cách thông thường là mỗi class màu viết một cặp: bg-zinc-900 dark:bg-zinc-50. Với vài trăm class thì việc sửa theme sau này thành cực hình. Thay vào đó, tôi để toàn bộ thang màu trung tính chạy qua CSS variables, và light mode chỉ là một khối đảo ngược thang đó:

css
html:not(.dark) {
  --color-omega-50: 24 24 27;     /* zinc-900 */
  --color-omega-100: 39 39 42;    /* zinc-800 */
  --color-omega-800: 244 244 245; /* zinc-100 */
  --color-omega-900: 250 250 250; /* zinc-50  */
}

Mọi component viết bg-omega-900 hay text-omega-400 tự đảo màu khi đổi theme, không sửa một dòng JSX nào. Chỉ những chỗ hardcode (bg-black, text-white) mới cần cặp dark: — và đó cũng là danh sách ngắn đáng để rà lại.

Static export vẫn làm được nhiều hơn bạn nghĩ

Bỏ server không có nghĩa là bỏ tính năng. Ba thứ tôi vẫn giữ:

Command palette ⌘K — dữ liệu tìm kiếm là một file JSON sinh lúc build. Một script Node quét frontmatter toàn bộ nội dung, ghi ra public/search-index.json, client chỉ việc fetch. Thêm bài mới không phải đụng vào code.

bash
node scripts/generate-search-index.js
# search-index: 15 entries -> public/search-index.json

Form liên hệ — gọi thẳng Web3Forms từ trình duyệt, không cần API route.

RSS và sitemap — sinh ở bước postbuild, ghi thẳng vào out/.

Điểm chung: mọi thứ đẩy về lúc build hoặc về phía client. Cái gì bắt buộc phải có server thì cân nhắc xem có thật sự cần không — phần lớn là không.

Checklist nếu bạn sắp nâng cấp

Rút ra sau khi làm thật, theo thứ tự nên chạy:

  1. Node ≥ 20.9 — Next 16 yêu cầu, và vài dependency sẽ chặn cứng bằng trường engines.
  2. Bỏ next export khỏi mọi npm script, chuyển sang output: 'export'.
  3. Rà file CSS: @import lên đầu, xoá mọi chuỗi */ nằm trong comment.
  4. next lint không còn — Next 16 gỡ lệnh này, gọi thẳng eslint . và chuyển sang flat config.
  5. Kiểm tra next/image: prop onLoadingComplete đã bị gỡ, dùng onLoad.
  6. Kiểm tra next/link: legacyBehaviorpassHref cũng không còn.
  7. Xoá .next/ trước lần chạy đầu — cache Turbopack cũ hay gây lỗi module lạ.

Điều bất ngờ nhất: phần khó không nằm ở API mới của Next, mà ở CSS. Nếu dự án của bạn có file style tích tụ nhiều năm, hãy dành thời gian cho nó trước.


Toàn bộ site này là mã nguồn mở về mặt cách làm — nếu bạn muốn xem kết quả, danh sách dự ántrang dịch vụ đều chạy trên đúng pipeline vừa mô tả.

Bài viết liên quan