blog

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

Ghi chép 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 lấy Turbopack làm bundler mặc định cho cả dev lẫn build.

Ba chỗ tốn thời gian nhất: CSS strict của Turbopack, lệnh next export biến mất, và cache dev bị xoá nhầm mỗi lần khởi động.

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 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ở. Ở thời điểm nâng cấp, deploy là build ở máy local, commit thư mục out/, rồi git pull trên server; sau đó tôi bỏ hẳn cách này, chuyển sang build static ngay trên server bằng CI/CD. Cả hai cách đều đứng được vì không có Node process nào 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. Dev server khởi động dưới một giây, nhưng phần CSS thì nghiêm ngặt hơn webpack. 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

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

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 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à đó là danh sách ngắn đáng rà lại.

Static export vẫn chạy được ⌘K, form liên hệ và RSS

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: 16 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. Tính năng nào bắt buộc phải có server thì cân nhắc lại xem có thật sự cần không.

Checklist nâng cấp Next.js 16

Theo thứ tự nên chạy:

  1. Node ≥ 20.9. Next 16 yêu cầu, và vài dependency 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: legacyBehavior và passHref cũng không còn.
  7. Xoá .next/ đúng một lần trước lần chạy đầu sau khi nâng cấp, vì cache Turbopack của bản cũ hay gây lỗi module lạ.

Chữ đúng một lần ở mục 7 là có chủ ý. Tôi từng nhét rimraf .next vào predev cho chắc, và đó là sai lầm: từ 16.2, turbopackFileSystemCacheForDev bật mặc định, cache dev nằm trong .next/. Xoá nó mỗi lần khởi động là vứt đi đúng thứ khiến Turbopack nhanh. Bỏ dòng đó ra, dev server khởi động trong 710ms.

Cách làm đúng là để dành việc xoá cho một script riêng, chỉ chạy khi HMR thật sự loạn (đổi tên file, di chuyển thư mục content/, sửa theme.config.js):

bash
npm run dev         # giữ cache, khởi động ~700ms
npm run dev:fresh   # rimraf .next && next dev — chỉ khi HMR desync

Phần khó của đợt nâng cấp này nằm ở CSS chứ không ở API mới của Next. Dự án có file style tích tụ nhiều năm thì dành thời gian cho nó trước.

Pipeline Markdown phía sau site (một route, mười layout, computed fields) nằm ở bài kiến trúc site Next.js chạy bằng Markdown.

Bài viết liên quan

tất cả bài viết →