Building a personal portfolio is a rite of passage for developers, but for Byte of Me, I wanted to go beyond a basic template. My goal was to build a streamlined, custom CMS—a full-stack monorepo designed to manage multi-language blogs, projects, and academic history through a single, private dashboard.
The High-Level Architecture
The system is built as a Monorepo. This allows me to share types and logic between the public-facing site and the private administrative tools seamlessly.
Frontend: Next.js (App Router) for Partial Prerendering (PPR) & SSR.
Database & ORM: PostgreSQL hosted on Supabase with Prisma as the type-safe bridge.
State Management: TanStack Query for client-side caching and optimistic UI updates.
Styling: Tailwind CSS + Framer Motion for a fluid, polished UX.
Monorepo Architecture: Unified Logic, Distributed Scale
I built Byte of Me as a monorepo to ensure architectural consistency and code reuse. By centralizing shared logic (like database access, logging, and storage utilities) in a packages/ directory, I can maintain a single "source of truth" while keeping the application code in apps/ lightweight and focused.
├── 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/: Contains the frontend application (web), where the focus is entirely on UI and feature delivery.packages/: Acts as a shared internal library (e.g.,@byte-of-me/db,@byte-of-me/logger). This ensures that type definitions and core business logic are strictly synced across the entire project.Tooling: Using Turborepo and pnpm workspaces, I achieve lightning-fast builds and efficient dependency management, allowing me to scale the platform without adding unnecessary overhead.
Frontend Design (apps/web): Feature-Sliced Design (FSD)
One of the biggest challenges in Next.js projects is folder structure. For this project, I implemented Feature-Sliced Design (FSD). This methodology helps decouple the code by dividing the app into strictly defined layers.
As seen in my design blueprint, the hierarchy ensures that logic only flows downward:
Shared: The foundation (UI Kit, global hooks)
i18n: For the shared next-intl.
Entities: Business logic related to objects like
Blog,Project, orEducation.Features: User actions like
auth,…Widgets: Complex compositions like the
site-headerorcontact-gallery.
Data Flow & Rendering Strategy
Byte of Me leverages the full power of the Next.js App Router. I use a hybrid approach to ensure the site is fast for users but easy to manage for me.
The Lifecycle of a Content Update:
Server Components: Fetch the initial content directly from Prisma during the request.
Server Actions: When I edit a post in the dashboard, a Server Action triggers the database update.
Revalidation: I use TanStack Query's
invalidateQueriesfor client-side cache andrevalidateTag(unstable_cache) for server-side cache, ensuring that stale data is purged instantly across the stack.
Note: Instead of relying on time-based expiration, I prefer On-demand Revalidation (Tag-based). By using revalidateTag, the cache is purged exactly when I hit 'Save.' Rather than 'guessing' whether the data is still fresh, I can be certain that users are always seeing the latest content."
Solving Internationalization (i18n)
A key requirement was support for multiple languages (English and Vietnamese). I split this into two distinct logic paths:
Static UI (Buttons, Labels)
I used next-intl to handle static strings via JSON files.
{
"nav": {
"projects": "Projects",
"blog": "Blog"
}
}
Dynamic Content & The Translation Workflow
To support multi-language content, I decoupled the Blog (metadata) from the BlogTranslation (content). This keeps my database clean and my queries fast.
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 to localized content
@@map("blogs")
}
model BlogTranslation {
// ...
language String // e.g., "en", "vi"
content String @db.Text // Rich text
blogId String @map("blog_id")
blog Blog @relation(fields: [blogId], references: [id], onDelete: Cascade)
@@unique([blogId, language]) // Ensures one translation per language
@@map("blog_translations")
}
Fetching the translated content is as simple as:
// 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]
);
}This allows the system to gracefully fallback to the existing content if a specific language version isn't available yet.
The Dashboard Demo: My dashboard uses a tabbed Language-Aware Editor.
When I switch between tabs (EN | VN), the form dynamically updates to target that specific translation record. This allows me to publish a post in one language and add translations later, completely independent of the core post metadata.
Media Management with Supabase
Handling images can be a bottleneck. I built a custom Media Manager that integrates with Supabase Storage (S3-compatible).
The Flow:
Upload a file via the Dashboard.
The file is stored in a Supabase Bucket.
The URL and metadata (size, dimensions) are saved to my
Mediatable in PostgreSQL via Prisma.On the frontend, images are served through the Next.js Image component for automatic WebP conversion and 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 represents a shift from “building a site” to “building a platform.” By combining the performance of Next.js, the safety of Prisma, and the organizational clarity of Feature-Sliced Design, I’ve created a home for my digital work.
Curious how it’s built under the hood? Check out the source code on GitHub:
👉 https://github.com/lthphuw/byte-of-me
Running great for now, but more updates are coming soon. Stay tuned! :v
Let's Connect
I’m always down to chat about clean code, FSD architecture, or new projects. If you have a question or just want to say hi, feel free to reach out!
Email: lthphuw@gmail.com
LinkedIn: www.linkedin.com/in/phu-lth
GitHub: lthphuw/byte-of-me

Comments
Must sign in to comment