Xây dựng "Byte of Me": Nền tảng Portfolio Full-Stack với Custom CMS
Làm portfolio cá nhân gần như là "nghi thức trưởng thành" của developer, nhưng với Byte of Me tôi muốn đi xa hơn một template cơ bản. Mục tiêu: một CMS tinh gọn tự xây — monorepo full-stack quản lý blog đa ngôn ngữ, dự án và quá trình học tập qua một dashboard riêng tư duy nhất.
Kiến trúc tổng quan
Toàn bộ hệ thống là một ứng dụng Next.js duy nhất chạy trên Vercel, phía sau là các managed service:
flowchart TB
V["Khách truy cập - en / vi"]
A["Tác giả - role ADMIN"]
subgraph vercel["Vercel"]
CDN["Edge cache"]
PUB["Trang public<br/>home · about · projects · blogs · contact"]
PROT["Dashboard - bảo vệ"]
ACT["Server actions"]
end
PG[("PostgreSQL<br/>Supabase")]
S3[("Object storage<br/>Supabase S3")]
V --> CDN -->|"cache 1h, SWR 24h"| PUB
A --> CDN -->|"no-store"| PROT
PUB --> ACT
PROT --> ACT
ACT --> PG
ACT --> S3
V -.->|"đọc ảnh trực tiếp"| S3- Frontend: Next.js 16 (App Router, Turbopack). Trang public được prerender tĩnh và phục vụ từ edge cache; dashboard render động với no-store.
- Database & ORM: PostgreSQL trên Supabase, Prisma 7 làm cầu nối type-safe — kết nối Postgres qua driver adapter @prisma/adapter-pg, nên app không phải ship kèm query-engine binary nào.
- Quản lý state: TanStack Query cho cache phía client của dashboard và optimistic update. Trang public không cần đến nó — Server Component đọc thẳng từ Prisma.
- Styling & animation: Tailwind CSS + Framer Motion, nạp lười qua LazyMotion để khách truy cập không phải trả phí cho phần animation ngay từ đầu.
Monorepo: Logic thống nhất, scale phân tán
Byte of Me là monorepo Bun + Turborepo. Logic dùng chung nằm trong packages/, giữ cho phần ứng dụng trong apps/ gọn và tập trung:
├── apps/
│ └── web/ # Ứng dụng Next.js
├── packages/
│ ├── ui/ # UI kit dùng chung: shadcn/ui, Tiptap editor, motion
│ ├── db/ # Prisma schema, client & seed
│ ├── storage/ # Client storage tương thích S3
│ ├── logger/ # Structured logging
│ └── config/ # TypeScript preset dùng chung
├── docs/
├── turbo.json
└── bun.lockBun đóng vai package manager (bun.lock, cùng workspaces trong package.json gốc), test runner (bun test — jest đã bỏ) và bundler cho db, storage, logger. Nhưng cố ý không phải runtime của ứng dụng: Next vẫn build và chạy trên Node, vì bun run --bun next build không resolve được conditional subpath exports của use-intl theo cách Node làm — cùng một build đó chạy trên Node thì xong trong khoảng 28 giây.
Mọi package được tiêu thụ dưới dạng TypeScript source qua transpilePackages — không cần build trước khi bun dev. Một chi tiết tôi đặc biệt quan tâm: @byte-of-me/ui expose subpath exports ./rich-text-editor, ./rich-text, ./lib/sanitize) thay vì một barrel khổng lồ. Nếu import barrel có re-export editor từ một component phía public, toàn bộ Tiptap sẽ bị kéo vào bundle của mọi khách truy cập — với subpath, editor chỉ tồn tại ở đúng nơi dùng nó.
Thiết kế Frontend: Feature-Sliced Design (FSD)
Cấu trúc thư mục là một trong những bài toán khó nhất khi codebase Next.js lớn dần. apps/web/src theo Feature-Sliced Design[1]: một tầng chỉ được import từ các tầng bên dưới, không bao giờ ngược lên, và không đi ngang giữa các slice.
flowchart TB
APP["app/ — routes · layouts · providers"]
WID["widgets/ — section tổng hợp<br/>public-site-header · blog-details-content"]
FEAT["features/ — năng lực người dùng<br/>blog-comment · blog-filters · media-library"]
ENT["entities/ — domain model + server API + UI<br/>blog · project · education · tag"]
SH["shared/ — config · hooks · i18n · lib · ui"]
APP --> WID --> FEAT --> ENT --> SHLuật: entity không bao giờ import feature. Nếu có vẻ cần, thì hoặc logic đó thuộc về entity, hoặc feature phải truyền nó vào.
Bên trong widgets/ và features/, các slice được nhóm theo đối tượng — public, dashboard, auth. Nguyên tắc "không bao giờ để lộ chức năng dashboard ra route public" hiện diện ngay trong cây thư mục thay vì bị chôn trong một guard nào đó.
Luồng dữ liệu, Cache & Tin cậy
Vòng đời của một lần cập nhật nội dung:
1. Server Component đọc nội dung trực tiếp từ Prisma trong request.
2. Server Action xử lý mọi mutation từ dashboard — và mỗi action đều mở đầu bằng requireAdmin(). Layout của dashboard cũng có guard riêng bảo vệ giao diện, nhưng server action là endpoint có thể gọi trực tiếp, nên guard ở tầng action mới là ranh giới bảo mật thật sự.
3. Revalidation xả dữ liệu cũ trên cả ba tầng cache:
Layer | Scope | Invalidated by |
|---|---|---|
Vercel edge | Public HTML, 1h + 24h SWR | Time, or a deploy |
Next.js data cache | Tagged queries |
|
TanStack Query | Dashboard client state |
|
Thay vì chỉ dựa vào hết hạn theo thời gian, tôi ưu tiên revalidation theo tag, đúng thời điểm. Cache bị xả chính xác vào lúc tôi bấm Save — không phải "đoán" xem dữ liệu còn tươi hay không.
Giải bài toán đa ngôn ngữ (i18n)
Yêu cầu cốt lõi là hỗ trợ tiếng Anh + tiếng Việt. Có hai hệ thống dịch tuyệt đối không được trộn lẫn:
UI tĩnh (nút bấm, nhãn, điều hướng)
Do next-intl đảm nhận qua file JSON — kèm type declaration được sinh tự động, nên gõ sai key là tsc báo lỗi ngay:
```json
{
"nav": {
"projects": "Projects",
"blog": "Blog"
}
}Nội dung động: tách metadata khỏi bản dịch
Nội dung tác giả viết nằm trong database, với model gốc (metadata) tách rời khỏi các bản dịch:
model Blog {
id String @id @default(cuid())
slug String @unique
publishedDate DateTime? @default(now()) @map("published_date")
isPublished Boolean @default(false) @map("is_published")
translations BlogTranslation[]
@@map("blogs")
}
model BlogTranslation {
language String
title String
content String @db.Text // Tài liệu Tiptap, lưu dạng JSON
blogId String @map("blog_id")
blog Blog @relation(fields: [blogId], references: [id], onDelete: Cascade)
@@unique([blogId, language]) // mỗi ngôn ngữ một bản dịch
@@map("blog_translations")
}Chọn đúng ngôn ngữ chỉ là một hàm nhỏ với chuỗi fallback có chủ đích — locale được yêu cầu → tiếng Anh → bất kỳ bản nào đang có:
// shared/lib/i18n-utils.ts
export function getTranslatedContent<T extends { language: string }>(
translations: T[],
locale: string
): T | undefined {
return (
translations.find((t) => t.language === locale) ||
translations.find((t) => t.language === 'en') ||
translations[0]
);
}Editor trong dashboard nhận biết ngôn ngữ, chia tab (EN | VI): chuyển tab là form nhắm vào đúng bản dịch đó, nên tôi có thể đăng bài bằng một ngôn ngữ rồi dịch sau — hoàn toàn độc lập với metadata của bài viết.
Pipeline Rich Text
Đây là chỗ "CMS tự viết" xứng đáng với cái tên. Bài viết được soạn bằng editor Tiptap trong dashboard và lưu dưới dạng tài liệu JSON, không phải HTML. Trên trang public, một server component chuyển JSON đó ngược thành HTML lúc render — qua một schema phản chiếu đúng schema của editor, cộng thêm sanitizer kiểu allowlist làm lớp chắn cuối chống stored XSS.
Pipeline hỗ trợ đúng những gì một blog kỹ thuật cần:
- Bảng và code block có syntax highlighting (~37 ngôn ngữ, highlight ngay trên server — client không tốn thêm byte nào)
- Sơ đồ Mermaid: code block ```mermaid render dạng source trên server, rồi một client component nhỏ thay nó bằng SVG đã vẽ — và thư viện mermaid ~500 KB chỉ được tải trên trang thực sự có sơ đồ
- Trích dẫn (citations) với số thứ tự tự suy ra và thư mục tham khảo sinh tự động
Điều tôi nghiêm ngặt nhất: editor không bao giờ được ship cho khách truy cập. Bundle soạn thảo (Tiptap + ProseMirror) chỉ nạp lười bên trong dashboard; trang public nhận HTML đã render sẵn và đã qua sanitize.
Hàng ảnh và caption
Tính năng mới nhất, và cũng là thứ bài viết này tự demo được. Hai ảnh trở lên có thể nằm cạnh nhau thành một row — mỗi ảnh có caption riêng, và cả row có thêm một caption chung:


Row là một <figure> thật và mỗi caption là một <figcaption> thật, nên cấu trúc giữ nguyên ở mọi nơi document đi qua — kể cả bản export PDF bên dưới, nơi break-inside: avoid giữ cho một row không bị cắt ngang trang. Dưới 640px row tự xếp dọc, nên so sánh vẫn đọc được trên điện thoại.
Export bài viết ra PDF
Mỗi bài viết đều tải về được dạng PDF từ action bar ở đầu trang. Đây không phải ảnh chụp màn hình: chính server component render ra trang sẽ sinh HTML tĩnh, rồi Chrome dàn chữ thật từ font thật — nên text trong PDF vẫn select và search được, và công thức toán vẫn giữ nguyên phần typeset của KaTeX.
Quản lý Media với Supabase
Upload đi xuyên qua server action, nên credential của storage không bao giờ chạm tới trình duyệt. Còn chiều đọc thì bỏ qua app hoàn toàn — next/image lấy ảnh thẳng từ host Supabase, kèm chuyển đổi định dạng tự động và lazy loading.
FUNCTION uploadMedia(files, scope)
// 1. Kiểm tra bảo mật — mọi mutation đều bắt đầu từ đây
IF requireAdmin() thất bại THEN RETURN error("Unauthorized")
// 2. Validate trước khi bất cứ thứ gì chạm tới storage. Check ở form
// upload chỉ là tiện cho người dùng; check ở đây mới là bảo đảm,
// vì mọi editor đều gọi thẳng vào đây.
violation = findUploadViolation(files) // type · size mỗi file · số file
IF violation THEN RETURN error(describe(violation))
FOR EACH file IN files:
// nhóm theo mục đích của ảnh, không chỉ theo thời điểm upload
path = "users/" + userId + "/media/" + scope + "/" + year + "/" + month + "/" + id
storage.putObject(path, file) // Bucket Supabase S3
db.media.create({ url, fileName, size }) // đánh chỉ mục trong Postgres
revalidateTag(CACHE_TAGS.MEDIA) // xả đúng phần cache vừa thay đổi
RETURN successLời kết
Byte of Me đại diện cho bước chuyển từ làm một trang web sang xây một nền tảng. Next.js cho hiệu năng, Prisma cho an toàn kiểu, Feature-Sliced Design cho sự mạch lạc — và một pipeline rich text cho phép chính bài viết này chứa bảng, sơ đồ và code highlight mà không phải ship editor cho bất kỳ khách truy cập nào.
Tò mò bên trong nó chạy thế nào? Source code có trên GitHub:
👉 https://github.com/lthphuw/byte-of-me
Hiện tại chạy ổn, nhưng sẽ còn nhiều cập nhật nữa. Đón chờ nhé! :v
Kết nối với tôi
Tôi luôn sẵn lòng trò chuyện về clean code, kiến trúc FSD, hay dự án mới. Có câu hỏi hay chỉ muốn chào một tiếng, cứ nhắn nhé!
- Email: lthphuw@gmail.com
- LinkedIn: https://www.linkedin.com/in/phu-lth
- GitHub: https://github.com/lthphuw/byte-of-me
References
- Usage with Next.js.https://feature-sliced.design/docs/guides/tech/with-nextjs

Bình luận
Bạn cần phải đăng nhập để bình luận