Back to Blog
Next.jsMongoDBTypeScriptTailwind CSS

Building My Portfolio Site: Architecture and Tech Stack

·10 min read

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

LayerTechnology
FrameworkNext.js 15 (App Router)
LanguageTypeScript
StylingTailwind CSS v4
DatabaseMongoDB Atlas
DB Drivermongodb (native Node.js driver)
DeploymentVercel

No ORM, no GraphQL, no extra abstraction. The native MongoDB driver is lightweight and plenty capable for a portfolio site.


Project Structure

CODE
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:

JSON
{
  "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:

JSON
{
  "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.

TypeScript
// 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.

TypeScript
// 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 useInView hook from react-intersection-observer to trigger the skill bar animations
  • The AnimatedCounter component 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.

TSX
// 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-dark theme
  • rehype-slug — adds id attributes 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 the content field 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

ParamTypeExample
qstring?q=mongodb
tagscomma-separated?tags=Next.js,TypeScript
sortasc or desc?sort=asc

The route builds a MongoDB filter from those params before hitting the database:

TypeScript
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 active
  • posts (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:

TypeScript
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:

TypeScript
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):

TypeScript
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:

TypeScript
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]:

TypeScript
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:

TypeScript
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:

CODE
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:

TypeScript
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.