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.
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.
/* Đú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:
/* 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 đó:
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.
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:
- Node ≥ 20.9. Next 16 yêu cầu, và vài dependency chặn cứng bằng trường
engines. - Bỏ
next exportkhỏi mọi npm script, chuyển sangoutput: 'export'. - Rà file CSS:
@importlên đầu, xoá mọi chuỗi*/nằm trong comment. next lintkhông còn. Next 16 gỡ lệnh này, gọi thẳngeslint .và chuyển sang flat config.- Kiểm tra
next/image: proponLoadingCompleteđã bị gỡ, dùngonLoad. - Kiểm tra
next/link:legacyBehaviorvàpassHrefcũng không còn. - 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):
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 →- 1 route, 10+ layout — kiến trúc site Next.js chạy hoàn toàn bằng MarkdownToàn bộ site này render từ đúng 1 file route. Pipeline phía sau: frontmatter chia section, computed fields, registry layout và collection.đọc tiếp →
- Bỏ commit thư mục out/: dựng CI/CD build static ngay trên serverRepo này có 29 trên 51 commit đụng vào thư mục build output. Cách bỏ hẳn nó: self-hosted runner build ngay trên server, publish bằng một thao tác đổi symlink, purge Cloudflare, và ba cái bẫy bash chỉ lộ ra khi đem ra test.đọc tiếp →
- Tự dựng dashboard giám sát uptime cho 30 website khách hàngKhách gọi báo web sập trước khi tôi biết. Cách theo dõi uptime, tốc độ phản hồi và hạn SSL của 30 site mà không sinh ra một núi cảnh báo rác.đọc tiếp →
- Claude Code + NotebookLM + Obsidian: dựng pipeline nghiên cứu, và nửa cầu nối không chính chủCách ghép Claude Code, NotebookLM và Obsidian thành pipeline nghiên cứu: skill nào có sẵn, vì sao NotebookLM không có API cho tài khoản cá nhân, và cái giá phải trả khi ghép ba công cụ này.đọc tiếp →