Xây dựng trình tạo website Multi-Tenant với Next.js: Chi tiết kiến trúc và bài học
Tôi đã dành vài tháng qua để xây dựng Zenpage, một công cụ tạo website miễn phí cho tác giả. Bài viết này chia sẻ cách sử dụng một codebase Next.js duy nhất để vận hành cả dashboard và hàng ngàn trang web người dùng thông qua kiến trúc định tuyến hostname.

Tôi đã dành vài tháng qua để xây dựng Zenpage, một trình tạo website miễn phí dành cho các tác giả. Điểm đặc biệt là chỉ với một codebase duy nhất, hệ thống này có thể phục vụ cả phần dashboard (nơi tác giả xây dựng site) và mọi trang web đã xuất bản (trên subdomain và tên miền tùy chỉnh) — tất cả chạy trong một lần deploy Next.js 15 duy nhất nhờ cơ chế định tuyến dựa trên hostname.
Bài viết này sẽ phân tích các quyết định kiến trúc, những kỹ thuật khó khăn và những gì tôi sẽ làm khác nếu bắt đầu lại từ đầu.
Bộ công nghệ sử dụng (The Stack)
- Runtime: Bun
- Framework: Next.js 15 App Router, React 19
- Styling: Tailwind CSS v4, shadcn/ui
- API: tRPC với SuperJSON
- Database: PostgreSQL với Drizzle ORM
- Storage: Cloudflare R2 (tương thích S3)
- Email: Resend
- Infrastructure: Cloudflare (CDN, SSL, DNS)
Không sử dụng CMS bên ngoài. Không headless nào cả. Chỉ một repo, một lần deploy, và một cơ sở dữ liệu duy nhất.
Vấn đề cốt lõi: Phục vụ N website từ một ứng dụng
Zenpage cần thực hiện hai việc:
- Phục vụ dashboard tại
zenpage.io(xác thực, chỉnh sửa, cài đặt). - Phục vụ trang web đã xuất bản của từng tác giả tại
authorname.zenpage.iohoặcauthorname.com.
Đa số mọi người sẽ giải quyết vấn đề này bằng cách tách biệt các ứng dụng hoặc sử dụng microservices. Tuy nhiên, tôi muốn dùng một codebase duy nhất. Middleware của Next.js đã khiến việc này trở nên sạch sẽ đáng ngạc nhiên.
Định tuyến Hostname
Ý tưởng rất đơn giản: middleware chặn mọi yêu cầu, kiểm tra hostname và quyết định nơi chuyển tiếp nó. Các hostname của dashboard sẽ được chuyển thẳng tới các tuyến đường (routes) quản trị. Mọi thứ khác sẽ được ghi lại (rewrite) thành một dynamic route, tìm kiếm trang web theo hostname trong cơ sở dữ liệu và render đúng template.
Việc triển khai thực tế phải xử lý nhiều trường hợp ngoại lệ mà tôi không lường trước được: chuyển hướng www., thực thi URL chuẩn (canonical), và fallback êm đẹp khi App Router gửi các tiêu đề trạng thái flight bị hỏng (một vấn đề khá thú vị để debug). Nhưng mô hình cốt lõi chỉ đơn giản là kiểm tra hostname + ghi lại URL.
Nhóm tuyến đường (Route groups) của Next.js giúp việc này hoạt động gọn gàng. Dashboard và các trang web đã xuất bản nằm trong các nhóm route hoàn toàn riêng biệt trong cùng một ứng dụng. Không có layout nào bị rò rỉ sang bên kia, không có render có điều kiện dựa trên "chế độ nào đang bật". Chúng thực chất là hai ứng dụng chia sẻ codebase và cơ sở dữ liệu.
Nếu bạn đang xây dựng bất kỳ loại ứng dụng multi-tenant nào với Next.js, mô hình này đáng để khám phá. Nó mở rộng tốt và giữ cho codebase đơn giản hơn nhiều so với việc chạy các triển khai riêng biệt.
Hệ thống Template
Zenpage có 5 mẫu thiết kế, mỗi mẫu có nhiều bảng màu — tổng cộng 21 kết hợp. Tôi cần một hệ thống mà ở đó:
- Các template thực sự khác biệt (không chỉ là bản sao đổi màu).
- Bảng màu có thể hoán đổi nóng (hot-swappable) mà không cần chạm vào mã component.
- Mỗi template có cặp phông chữ và tính cách layout riêng.
Mỗi template được định nghĩa trong một registry có kiểu dữ liệu (typed registry) và render dưới dạng các React component nhận một đối tượng màu sắc (colors object) làm props. Các màu sắc được áp dụng thông qua inline styles và CSS custom properties, do đó việc đổi bảng màu chỉ đơn giản là thay đổi props — không cần thay đổi mã component.
Một mô hình hữu ích: thêm hai ký tự hex alpha vào giá trị màu (${colors.primary}20) sẽ cung cấp các biến thể trong suốt mà không cần rgba() hoặc các token màu bổ sung. 20 là ~12% độ mờ, 45 là ~27%, v.v. Điều này giữ cho hệ thống màu tối giản nhưng vẫn cho phép tạo các lớp tinh tế.
Cạm bẫy với Tailwind v4
Tôi mất nhiều thời gian hơn để tìm ra vấn đề này hơn là tôi muốn thừa nhận. Tailwind v4 sử dụng CSS @layer cho phần reset của nó (Preflight), điều này có nghĩa là các style trong globals.css không thể ghi đè lên Preflight một cách đáng tin cậy do thứ tự xếp chồng layer. Bất kỳ HTML thô nào được render qua dangerouslySetInnerHTML (như nội dung blog markdown đã được làm sạch) đều bị tách khỏi kích thước tiêu đề, lề, kiểu danh sách — mọi thứ.
Giải pháp thay thế: sử dụng các thẻ <style> nội tuyến bên trong các component render HTML đã được làm sạch. Các thẻ này bỏ qua quá trình xử lý PostCSS của Tailwind hoàn toàn và áp dụng style trực tiếp vào nội dung được render.
function BlogContent({ sanitizedHtml }: { sanitizedHtml: string }) {
return (
<article>
<style>{`
.blog-content h1 { font-size: 2em; margin: 0.67em 0; font-weight: bold; }
.blog-content h2 { font-size: 1.5em; margin: 0.83em 0; font-weight: bold; }
.blog-content p { margin: 1em 0; }
.blog-content ul { list-style: disc; padding-left: 2em; }
/* ... */
`}</style>
<div className="blog-content" dangerouslySetInnerHTML={{ __html: sanitizedHtml }} />
</article>
)
}
Nó không thanh lịch, nhưng hoạt động đáng tin cậy. Tất cả nội dung đều được làm sạch ở phía máy chủ trước khi render. Nếu bạn đang sử dụng Tailwind v4 với nội dung HTML do người dùng tạo, đây có lẽ là giải pháp đơn giản nhất.
Quy trình xử lý hình ảnh
Các tác giả tải lên ảnh bìa và chân dung ở mọi định dạng và kích thước imaginable. Endpoint tải lên sẽ xác thực, thay đổi kích thước, chuyển đổi sang WebP và lưu trữ kết quả trên Cloudflare R2. Tên file bất biến (immutable) có nghĩa là CDN có thể cache mãi mãi.
Một vài điều tôi học được một cách đau đớn khi xử lý hình ảnh với Sharp:
- Không bao giờ phóng to (upscale). Tùy chọn
withoutEnlargementcủa Sharp là thiết yếu. Nếu không, một chân dung rộng 600px sẽ bị kéo dài thành một mớ hỗn độn nhòe ở kích thước mục tiêu của bạn. - Cài đặt chất lượng WebP quan trọng hơn bạn nghĩ, đặc biệt là đối với bìa sách. Nén trông ổn trên ảnh chụp có thể tạo ra các hiện vật (artifacts) nhìn thấy được trên bìa sách có văn bản sắc nét và màu phẳng. Hãy dành thời gian tìm điểm ngọt ngào cho bạn.
- Sử dụng giới hạn kích thước khác nhau cho các loại nội dung khác nhau. Một chân dung không cần cùng độ phân giải với một ảnh blog lớn. Điều này nghe có vẻ hiển nhiên nhưng rất dễ chỉ chọn một chiều rộng tối đa và coi như xong.
Nhập dữ liệu sách
Một trong những tính năng tôi hài lòng nhất là nhập sách. Tác giả chỉ cần nhập một ISBN, và Zenpage sẽ kéo toàn bộ danh mục của họ: bìa, mô tả và metadata.
Bài học kiến trúc quan trọng ở đây là về độ bền (resilience). Các API dữ liệu sách không đáng tin cậy. Bất kỳ nguồn đơn lẻ nào cũng sẽ bị lỗi, không đầy đủ hoặc thiếu cuốn sách cụ thể của bạn tại một thời điểm nào đó. Giải pháp là truy vấn nhiều nguồn song song bằng cách sử dụng Promise.allSettled() thay vì Promise.all(), để một thất bại không chặn các nguồn khác. Nếu ứng dụng của bạn phụ thuộc vào các API dữ liệu bên thứ ba, hãy thiết kế cho sự thất bại một phần ngay từ đầu.
Thiết kế Cơ sở dữ liệu
Một vài quyết định về lược đồ (schema) đã định hình phần còn lại của kiến trúc:
Một website cho mỗi người dùng. Một ràng buộc duy nhất trên ID người dùng trong bảng website làm cho đây become mối quan hệ một-một cứng. Bạn không thể vô tình tạo website thứ hai. Điều này đơn giản hóa toàn bộ API — mọi yêu cầu đã xác thực đều ngầm đề cập đến "website của người dùng". Không có bộ chọn site, không có quản lý đa site, không có sự mơ hồ.
Tính duy nhất của Slug kết hợp. Trong ứng dụng multi-tenant, các slug cần một chỉ mục duy nhất kết hợp (composite unique index) được phạm vi trong site. Hai tác giả khác nhau có thể đều có một sách có slug là the-great-gatsby, nhưng trong một site duy nhất, các slug phải là duy nhất. Điều này rất dễ sai nếu bạn quen với các ứng dụng single-tenant nơi một slug duy nhất toàn cục là ổn.
Xóa chuỗi (Cascade deletes) để dọn dẹp sạch sẽ. Khi người dùng xóa tài khoản của họ, mọi thứ sẽ xóa chuỗi: website, nội dung, bài đăng blog, sự kiện. Không có hàng mồ côi, không có công việc dọn dẹp. Drizzle làm cho điều này mang tính khai báo trong định nghĩa lược đồ.
Tối ưu hóa SEO cho từng site
Mỗi trang web đã xuất bản có sitemap, RSS feed và robots.txt được tạo động riêng của nó. Đây là các trình xử lý tuyến đường Next.js truy vấn cơ sở dữ liệu và trả về XML.
Một vài quyết định đáng chú ý:
- RSS feed trả về 404 khi trống. Nếu một tác giả chưa đăng bài viết blog nào, endpoint feed sẽ trả về 404 thay vì một feed trống. Điều này ngăn chặn các công cụ tìm kiếm index một trang vô dụng.
lastmodsử dụng timestamp thực tế. Sitemaps lấy từ trườngupdatedAtthực trên mỗi bản ghi, không phải thời gian tạo. Các công cụ爬虫 dùng điều này để quyết định xem có nên re-index một trang hay không.- Hủy bỏ cache dựa trên thẻ (Tag-based cache invalidation). Sitemaps và feeds được cache với ISR để chúng không đánh vào cơ sở dữ liệu trên mọi yêu cầu, nhưng sẽ bị vô hiệu hóa khi nội dung thay đổi.
tRPC
API sử dụng tRPC với SuperJSON để serialization và một chuỗi middleware cho xác thực. Hai tầng: công khai và đã xác thực. Middleware xác thực thu hẹp kiểu ngữ cảnh (context type) để ctx.user được đảm bảo không null trong các quy trình được bảo vệ. TypeScript thực thi điều này tại thời gian biên dịch.
SuperJSON như một bộ chuyển đổi (transformer) là một trong những quyết định nhỏ giúp tiết kiệm hàng giờ debug. Dates, Maps và Sets serialization chính xác qua mạng. Không còn sự phiền toái JSON.parse(JSON.stringify(date)).
Viết trình phân tích cú pháp markdown tùy chỉnh (và tại sao tôi hối tiếc)
Thay vì kéo vào một thư viện markdown, tôi đã viết một trình phân tích cú pháp tùy chỉnh. Nó xử lý tiêu đề, nhấn mạnh, liên kết, hình ảnh, blockquote, khối mã, danh sách lồng nhau và bảng GFM.
Nó hoạt động. Nhưng mọi trường hợp ngoại lệ mà tôi không nghĩ đến đều trở thành một báo cáo lỗi. Blockquote lồng nhau bên trong mục danh sách. Bảng có ký tự ống trong nội dung ô. Khối mã chứa cú pháp markdown. Mỗi cái là một sửa nhỏ, nhưng chúng tích tụ lại.
Nếu tôi bắt đầu lại, tôi sẽ sử dụng unified với remark và rehype. Trình phân tích tùy chỉnh là một bài tập thú vị, nhưng không phải là loại mã bạn muốn duy trì mãi mãi trên một ứng dụng sản xuất. Bài học: chỉ xây dựng từ đầu khi các công cụ hiện có thực sự không phù hợp với trường hợp sử dụng của bạn.
Những gì tôi sẽ làm khác đi
Sử dụng một thư viện markdown thích hợp. Đã đề cập ở trên. Trình phân tích tùy chỉnh mang tính giáo dục nhưng không thực tế về lâu dài.
Tách biệt CSS cho template sạch sẽ hơn. Inline styles hoạt động nhưng làm cho các component template trở nên dài dòng. Vấn đề layer của Tailwind v4 đã đẩy tôi về phía inline styles, nhưng có lẽ có một sự thỏa hiệp tốt hơn bằng cách sử dụng CSS modules hoặc scoped styles.
Thêm phân tích (analytics) từ ngày đầu tiên. Tôi đã hoãn analytics và giờ tôi đang gắn lại nó. Xây dựng các bảng theo dõi vào lược đồ ban đầu sẽ đã tiết kiệm một lần di chuyển (migration).
Zenpage đã trực tuyến tại zenpage.io. Nếu bạn là một tác giả (hoặc biết một tác giả), bạn có thể tạo một trang web miễn phí trong khoảng 15 phút.
Nếu bạn là một nhà phát triển quan tâm đến kiến trúc Next.js multi-tenant hoặc xây dựng một hệ thống template với các biến CSS, tôi hy vọng bản phân tích này hữu ích. Rất vui được trả lời câu hỏi trong phần bình luận.
Bài viết liên quan

Phần mềm
Anthropic ra mắt Claude Opus 4.7: Nâng cấp mạnh mẽ cho lập trình nhưng vẫn thua Mythos Preview
16 tháng 4, 2026

Công nghệ
Qwen3.6-35B-A3B: Quyền năng Lập trình Agentic, Nay Đã Mở Cửa Cho Tất Cả
16 tháng 4, 2026

Công nghệ
Spotify thắng kiện 322 triệu USD từ nhóm pirate Anna's Archive nhưng đối mặt với bài toán thu hồi
16 tháng 4, 2026
