Building My Portfolio Site: Architecture and Tech Stack
This post is a technical walkthrough of how this portfolio site is built. I'll cover the folder structure, the data layer, and the key design decisions — including why I store all profile content in MongoDB instead of hardcoding it.
Tech Stack
| Layer | Technology |
|---|---|
| Framework | Next.js 15 (App Router) |
| Language | TypeScript |
| Styling | Tailwind CSS v4 |
| Database | MongoDB Atlas |
| DB Driver | mongodb (native Node.js driver) |
| Deployment | Vercel |
No ORM, no GraphQL, no extra abstraction. The native MongoDB driver is lightweight and plenty capable for a portfolio site.
Project Structure
src/
├── app/
│ ├── api/
│ │ ├── profile/
│ │ │ └── route.ts # GET profile data
│ │ └── blog/
│ │ ├── route.ts # GET all published posts
│ │ └── [slug]/
│ │ └── route.ts # GET single post by slug
│ ├── blog/
│ │ ├── page.tsx # Blog list page
│ │ └── [slug]/
│ │ └── page.tsx # Individual post page
│ ├── type/
│ │ ├── profileData.ts # TypeScript types for profile
│ │ └── blogPost.ts # TypeScript types for blog posts
│ ├── globals.css
│ ├── layout.tsx
│ └── page.tsx # Homepage (server component)
├── components/
│ ├── Header.tsx # Homepage header (scroll-based nav)
│ ├── BlogHeader.tsx # Blog header (link-based nav)
│ ├── HomeClient.tsx # Main homepage client component
│ ├── AnimatedCounter.tsx # Skill level animation
│ └── MarkdownRenderer.tsx # Renders blog post markdown
├── icon/
│ ├── LinkedInIcon.tsx
│ ├── InstagramIcon.tsx
│ └── EmailIcon.tsx
└── lib/
└── mongo.ts # MongoDB client singleton
Data Layer: MongoDB
All content — my bio, work experience, skills, education, and socials — lives in MongoDB Atlas. The site never has hardcoded copy; a CMS writes to the database and the site reads from it.
There are two collections:
profiles — a single document holding all homepage content:
{
"homeIntroText": "...",
"about": {
"aboutText1": "...",
"aboutText2": "...",
"professionalTech": ["TypeScript", "React", "..."],
"academicTech": ["Python", "Docker", "..."]
},
"workExperience": [{ "title": "...", "company": "...", "date": "...", "location": "...", "desc": "..." }],
"skills": [{ "name": "Frontend", "level": 85, "tech": "React, Next.js, Tailwind" }],
"education": [{ "degree": "...", "school": "...", "date": "...", "location": "...", "details": "..." }],
"socials": [{ "name": "LinkedIn", "url": "...", "iconName": "LinkedInIcon" }]
}
blogs — one document per post:
{
"slug": "my-first-post",
"title": "Post Title",
"date": "2026-05-24",
"tags": ["Next.js", "TypeScript"],
"excerpt": "Short preview shown on the blog list.",
"content": "# Full markdown content...",
"published": true
}
Setting published: false acts as a draft flag — the API filters it out.
The MongoDB Singleton
Next.js in development mode hot-reloads modules constantly. Without a singleton, every reload would open a new database connection and exhaust the connection pool quickly.
// src/lib/mongo.ts
const globalForMongo = globalThis as typeof globalThis & {
_mongoClientPromise?: Promise<MongoClient>;
};
if (process.env.NODE_ENV === 'development') {
if (!globalForMongo._mongoClientPromise) {
client = new MongoClient(uri, options);
globalForMongo._mongoClientPromise = client.connect();
}
clientPromise = globalForMongo._mongoClientPromise;
} else {
// In production each serverless instance gets its own connection
client = new MongoClient(uri, options);
clientPromise = client.connect();
}
In production on Vercel, each serverless function invocation manages its own connection — there are no persistent processes to worry about.
Server vs. Client Components
Next.js App Router separates components into server and client. I use this boundary deliberately.
Server components handle data fetching. The homepage (app/page.tsx) is a server component that calls the /api/profile route and passes the result down as props. This means the first paint already has all the content — no loading spinners.
// app/page.tsx — server component
export default async function Home() {
const res = await fetch(`${baseUrl}/api/profile`, { cache: 'no-cache' });
const profileData = await res.json();
return <HomeClient profileData={profileData} />;
}
Client components handle interactivity. HomeClient.tsx is marked 'use client' because it needs:
- Scroll-based section navigation in the header
- The
useInViewhook fromreact-intersection-observerto trigger the skill bar animations - The
AnimatedCountercomponent that counts up to each skill percentage
The split keeps the server bundle lean and avoids shipping unnecessary JavaScript to the browser.
Skill Bar Animations
When the skills section scrolls into view, the progress bars animate from 0% to their target value and the percentage counter ticks up simultaneously.
// AnimatedCounter.tsx
useEffect(() => {
if (!isVisible) return;
const steps = 1000 / 20; // 50 steps over 1 second
const increment = target / steps;
const counter = setInterval(() => {
start += increment;
if (start >= target) { start = target; clearInterval(counter); }
setCount(Math.floor(start));
}, 20);
return () => clearInterval(counter);
}, [isVisible, target]);
The isVisible flag comes from useInView with triggerOnce: false, so the animation re-runs every time the section enters the viewport.
Blog System
The blog was added after the initial build. It uses react-markdown with a few plugins:
- remark-gfm — GitHub Flavored Markdown: tables, strikethrough, task lists
- rehype-highlight — syntax highlighting via highlight.js with the
github-darktheme - rehype-slug — adds
idattributes to headings for anchor links
All markdown components are overridden in MarkdownRenderer.tsx to match the site's dark blue colour palette instead of using browser defaults.
The blog has two routes:
/blog— lists all published posts (the API omits thecontentfield for performance)/blog/[slug]— fetches the full post including the markdown body
The blog pages use BlogHeader instead of Header. The homepage header uses scrollIntoView to navigate between sections; on a separate page that makes no sense, so BlogHeader uses <Link href="/#section-id"> instead.
Search, filter, and sort
The blog list passes search queries, tag filters, and sort order to the API as query parameters. MongoDB handles all the filtering and sorting, and the client just renders what comes back.
API query parameters
| Param | Type | Example |
|---|---|---|
q | string | ?q=mongodb |
tags | comma-separated | ?tags=Next.js,TypeScript |
sort | asc or desc | ?sort=asc |
The route builds a MongoDB filter from those params before hitting the database:
const filter: Record<string, unknown> = { published: true };
if (q) {
filter.$or = [
{ title: { $regex: q, $options: 'i' } },
{ excerpt: { $regex: q, $options: 'i' } },
];
}
if (tags.length > 0) {
filter.tags = { $in: tags };
}
const posts = await collection.find(filter).sort({ date: sort }).toArray();
Search uses a case-insensitive $regex on both the title and excerpt fields. Tag filter uses $in, which matches a post if it contains at least one of the selected tags (OR logic). Sort is just a 1 or -1 on the date field.
Client state
The server component fetches the full post list once and passes it to BlogListClient as initialPosts. The client keeps two arrays:
initialPosts(prop, never changes) — used for autocomplete suggestions and tag chip generation, both of which need the full list regardless of what filter is activeposts(state) — the filtered results shown in the list, replaced on every API response
When no filters are active, the client skips the API call entirely and renders initialPosts directly:
if (!debouncedQuery && selectedTags.length === 0 && sortOrder === 'newest') {
setPosts(initialPosts);
return;
}
Debouncing
Search uses two separate state values. query updates on every keystroke and drives the autocomplete dropdown immediately. debouncedQuery updates after a 300ms delay and is the value actually sent to the API:
useEffect(() => {
const timer = setTimeout(() => setDebouncedQuery(query), 300);
return () => clearTimeout(timer);
}, [query]);
Tag and sort changes trigger the API immediately since they are discrete selections, not continuous input. Each fetch uses an AbortController so a new request cancels any in-flight previous one. During a fetch, the post list dims to 40% opacity to indicate loading without hiding the previous results.
Pagination
The blog list shows 6 posts per page. The API, client state, and UI all work together to handle both the unfiltered and filtered cases differently.
API
When the client sends a page query param, the API switches to a paginated response shape. Without it, it returns a flat array (used by the server component for initialPosts):
if (paginated) {
const [total, rawPosts] = await Promise.all([
col.countDocuments(filter),
col.find(filter).sort({ date: sort }).skip((page - 1) * limit).limit(limit).toArray(),
]);
return NextResponse.json({ posts, total, page, totalPages: Math.ceil(total / limit) });
} else {
// flat array — server component path
const rawPosts = await col.find(filter).sort({ date: sort }).toArray();
return NextResponse.json(posts);
}
countDocuments and find run in parallel with Promise.all to avoid two sequential round-trips.
Client state
The client tracks currentPage alongside the filter state. Page resets to 1 whenever search, tags, or sort changes. The fetch effect depends on all four values:
useEffect(() => {
if (!debouncedQuery && selectedTags.length === 0 && sortOrder === 'newest') {
// No filters: paginate the full initialPosts array client-side
const start = (currentPage - 1) * POSTS_PER_PAGE;
setPosts(initialPosts.slice(start, start + POSTS_PER_PAGE));
setTotalPosts(initialPosts.length);
return;
}
// Filters active: call API with page + filter params
fetch(`/api/blog?${params}`)
.then(r => r.json())
.then((data: PaginatedResponse) => {
setPosts(data.posts);
setTotalPosts(data.total);
});
}, [debouncedQuery, selectedTags, sortOrder, currentPage, initialPosts]);
When there are no active filters, the client skips the API call entirely and slices initialPosts locally. This keeps navigation between pages of the default list instant with no network round-trips.
Pagination UI
Page numbers use a range function that adds ellipsis for large page counts. On page 5 of 12, it produces [1, '...', 4, 5, 6, '...', 12]:
function getPaginationRange(current: number, total: number): (number | '...')[] {
if (total <= 7) return Array.from({ length: total }, (_, i) => i + 1);
const result: (number | '...')[] = [1];
if (current > 3) result.push('...');
for (let p = Math.max(2, current - 1); p <= Math.min(total - 1, current + 1); p++) {
result.push(p);
}
if (current < total - 2) result.push('...');
result.push(total);
return result;
}
The nav shows Prev and Next buttons (disabled at boundaries), numbered page buttons, and a "Page X of Y" label. When the page changes, the list scrolls back to the top of the post list using a ref anchor.
Read time
Each post displays an estimated read time next to the date, calculated from the word count of the markdown content at 200 words per minute.
On the list page, the API computes it server-side so the content field itself is never sent to the client:
const serialized = posts.map((post) => {
const wordCount = post.content.trim().split(/\s+/).length;
const readTime = Math.max(1, Math.ceil(wordCount / 200));
const { content: _content, ...rest } = post;
return { ...rest, _id: post._id.toString(), readTime };
});
On the individual post page, the same calculation runs server-side since the full content is already available.
Design
The colour palette is a dark navy gradient:
from-[#081B29] via-[#0D2D4A] to-[#134074]
Accent colour is Tailwind's blue-400 (#60a5fa). All cards use a semi-transparent bg-blue-900/20 with a border-blue-400/20 border to create depth without heavy contrast.
The font is Geist Sans (by Vercel) for body text and Geist Mono for code, loaded through next/font/google.
Deployment
The site is deployed on Vercel. The VERCEL_URL environment variable is used to construct absolute URLs for server-side fetch calls, since relative URLs don't work in a Node.js context:
const baseUrl = process.env.VERCEL_URL
? `https://${process.env.VERCEL_URL}`
: 'http://localhost:3000';
MongoDB Atlas is configured with TLS enabled (tls: true) and the connection string is stored in .env / Vercel environment variables.
That covers the full architecture. The main takeaway is keeping things simple: one database, no ORM, clear server/client boundaries, and content managed externally so the site never needs a redeploy just to update copy.