Việc làm personal portfolio gần như là điều ai làm dev cũng sẽ trải qua, nhưng với Byte of Me, mình không muốn chỉ dừng lại ở một template cơ bản. Mục tiêu của mình là build một CMS custom gọn nhẹ — một full-stack monorepo giúp quản lý multi-language blogs, projects và academic history trong cùng một dashboard riêng, bảo mật hơn.
High-Level Architecture
Hệ thống được xây theo hướng Monorepo. Cách này giúp mình dễ dàng share types và logic giữa public-facing site và các công cụ admin riêng một cách mượt mà.
- Frontend: Next.js (App Router) để tận dụng Partial Prerendering (PPR) & SSR.
- Database & ORM: PostgreSQL host trên Supabase, dùng Prisma làm lớp bridge type-safe.
- State Management: TanStack Query cho client-side caching và optimistic UI updates.
- Styling: Tailwind CSS + Framer Motion để tạo UX mượt và polished hơn.
Monorepo Architecture: Unified Logic, Distributed Scale
Mình xây dựng Byte of Me theo mô hình monorepo để đảm bảo tính nhất quán trong kiến trúc và tối ưu code reuse. Bằng cách gom tất cả logic dùng chung (như database access, logging, và storage utilities) vào thư mục packages/, mình có thể duy trì một "source of truth" duy nhất, đồng thời giữ cho phần application code ở thư mục apps/ luôn gọn nhẹ và tập trung vào tính năng.
├── apps/
│ └── web/ # Next.js frontend
├── packages/
│ ├── db/ # Prisma client & schema
│ ├── logger/ # Shared logging logic
│ └── storage/ # Supabase S3 integration
├── docs/ # Docs
├── turbo.json # Turborepo configuration
└── pnpm-workspace.yaml # Monorepo workspace setupapps/: Chứa frontend application (web), nơi tập trung hoàn toàn vào UI và việc triển khai các tính năng.packages/: Đóng vai trò là thư viện nội bộ dùng chung (ví dụ:@byte-of-me/db,@byte-of-me/logger). Điều này đảm bảo các type definitions và core business logic luôn được đồng bộ chặt chẽ trên toàn bộ project.Tooling: Nhờ sử dụng Turborepo và pnpm workspaces, mình đạt được tốc độ build cực nhanh cùng khả năng quản lý dependencies hiệu quả, cho phép scale nền tảng mà không bị quá tải bởi các cấu hình rườm rà.
Frontend Design (apps/web): Feature-Sliced Design (FSD)
Một trong những vấn đề lớn nhất khi làm Next.js project là tổ chức folder structure. Với project này, mình áp dụng Feature-Sliced Design (FSD). Cách tiếp cận này giúp tách biệt code rõ ràng bằng cách chia app thành các layer có định nghĩa cụ thể.
Như trong design blueprint, cấu trúc này đảm bảo logic chỉ đi theo một chiều từ trên xuống:
Shared: Nền tảng (UI Kit, global hooks)
i18n: Dùng cho next-intl dùng chung
Entities: Business logic cho các object như Blog, Project, hoặc Education
Features: Các user actions như auth, ...
Widgets: Các phần Ui lớn phức tạp như site-header hoặc contact-gallery
Data Flow & Rendering Strategy
Byte of Me tận dụng tối đa sức mạnh của Next.js App Router. Mình dùng hybrid approach để vừa đảm bảo site nhanh cho user, vừa dễ quản lý.
Lifecycle của một content update:
Server Components: Fetch dữ liệu ban đầu trực tiếp từ Prisma trong request.
Server Actions: Khi mình edit bài viết trong dashboard, một Server Action sẽ trigger update database.
Revalidation: Dùng TanStack Query với
invalidateQueriescho client-side &revalidateTag(unstable_cache) cho server-side để clear cache ngay lập tức.
Note: Thay vì để cache tự động hết hạn theo thời gian, mình ưu tiên dùng On-demand Revalidation (Tag-based). Với revalidateTag, cache chỉ được xóa sạch đúng lúc mình nhấn Lưu; thay vì phải "đoán già đoán non", mình luôn chắc chắn rằng người dùng lúc nào cũng thấy nội dung mới nhất.
Internationalization (i18n)
Một yêu cầu quan trọng là support nhiều ngôn ngữ (English và Vietnamese). Mình tách ra thành 2 luồng xử lý riêng:
Static UI (Buttons, Labels)
Dùng next-intl để handle các static string thông qua JSON:
{
"nav": {
"projects": "Projects",
"blog": "Blog"
}
}Nội dung động & Quy trình xử lý đa ngôn ngữ
Để hỗ trợ nội dung đa ngôn ngữ, mình đã tách biệt Blog (dữ liệu meta) ra khỏi BlogTranslation (nội dung cụ thể). Cách tiếp cận này giúp cơ sở dữ liệu luôn gọn gàng và tối ưu hóa tốc độ truy vấn.
Code snippet (schema.prisma):
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[] // Relation tới các bản dịch
@@map("blogs")
}
model BlogTranslation {
language String // ví dụ: "en", "vi"
content String @db.Text
blogId String @map("blog_id")
blog Blog @relation(fields: [blogId], references: [id], onDelete: Cascade)
@@unique([blogId, language]) // Đảm bảo mỗi ngôn ngữ chỉ có một bản dịch duy nhất
@@map("blog_translations")
}
Việc lấy nội dung dịch như sau:
// some public fetch function
const entity = await prisma.<entity>.findUniqueOrThrow({
where: { ... },
include: {
...,
translations: true, // fetch all the translations
}
});
const translated = getTranslatedContent(entity.translations, locale);
// shared/lib/i18n-utilts.ts
export function getTranslatedContent<T extends { language: string }>(
translations: T[],
locale: string
): T {
return (
translations.find((t) => t.language === locale) ||
translations.find((t) => t.language === 'en') ||
translations[0]
);
}Điều này cho phép hệ thống tự động "fallback" (lấy nội dung mặc định) sang ngôn ngữ có sẵn nếu bài viết chưa được dịch sang ngôn ngữ người dùng đang chọn.
Demo Dashboard: Dashboard sử dụng một Language-Aware Editor (Trình soạn thảo nhận diện ngôn ngữ) theo dạng tab.
Khi mình chuyển đổi giữa các tab (EN | VN), form nhập liệu sẽ tự động cập nhật để nhắm vào đúng bản ghi dịch thuật đó. Điều này cho phép mình xuất bản bài viết bằng một ngôn ngữ trước, sau đó thêm các bản dịch khác vào sau mà không gây ảnh hưởng đến dữ liệu gốc của bài viết.
Media Management with Supabase
Việc xử lý image thường là một bottleneck. Mình build một Media Manager riêng, tích hợp với Supabase Storage (S3-compatible).
Flow:
Upload file từ Dashboard
File được lưu vào Supabase Bucket
URL và metadata (size, dimensions) được lưu vào bảng Media trong PostgreSQL thông qua Prisma
Ở frontend, image được render bằng Next.js Image để tự động convert sang WebP và lazy loading
Pseudocode:
FUNCTION uploadMedia(files)
// 1. Security Check
IF user is NOT admin THEN
RETURN error("Unauthorized")
// 2. Prepare the Storage Pipeline
FOR EACH file IN files:
// Create a unique, organized path (e.g., /2026/04/image_id.png)
path = "users/me/media/" + current_date + "/" + random_id()
// Save to Cloud Storage
save_to_cloud(path, file)
// Index in Database (so we can find it later)
database.save({
url: path,
fileName: file.name,
size: file.size
})
// 3. Refresh the App
// Tell the website that new content is ready!
purge_cache("MEDIA_TAG")
RETURN successWrapping Up
Byte of Me không chỉ là “build một website” mà là “build một platform”. Kết hợp performance của Next.js, type safety của Prisma và cách tổ chức rõ ràng từ Feature-Sliced Design, mình đã tạo ra một nơi để quản lý và phát triển toàn bộ digital work của mình.
Nếu bạn muốn xem chi tiết cách mình triển khai, có thể tham khảo source code tại GitHub:
👉 https://github.com/lthphuw/byte-of-me
Hiện tại project chạy khá ổn, nhưng mình chắc chắn sẽ còn update thêm nhiều thứ trong tương lai. Stay tuned! :v
Liên hệ
Mình luôn sẵn sàng thảo luận về clean code, kiến trúc FSD hoặc các dự án mới. Nếu bạn có thắc mắc hay chỉ muốn kết nối, đừng ngần ngại nhắn tin cho mình nhé!
Email: lthphuw@gmail.com
LinkedIn: www.linkedin.com/in/phu-lth
GitHub: lthphuw/byte-of-me

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