How I Built This Developer Portfolio
A technical walkthrough of building my portfolio site with Next.js 15, TypeScript, Tailwind CSS, and a dual-source blog system powered by MDX files and SQLite.
How I Built This Developer Portfolio
Most developer portfolios are either over-engineered React showcases with particle backgrounds and zero content, or bare HTML pages with a list of links. I wanted something in between — technically interesting enough to demonstrate skill, but focused on the content that actually gets you hired: clear project descriptions, real work experience, and genuine blog posts.
Here's how I built it.
Tech stack decisions
Next.js 15 with App Router — I chose Next.js over a static site generator because I wanted server-side rendering for the blog (SEO matters for technical writing) and API routes for the blog's CRUD operations. The App Router's server components mean most of the portfolio ships zero JavaScript to the client.
TypeScript in strict mode — Every file is typed. Not because it's trendy, but because this is a project I maintain alone. When I come back after a month to add a new project entry, TypeScript tells me exactly what shape the data needs to be.
Tailwind CSS — The entire site uses a consistent design system through CSS custom properties and Tailwind utility classes. The dark theme is defined once:
:root {
--bg: 0 0% 0%;
--bg-secondary: 0 0% 7%;
--text: 0 0% 100%;
--accent: 188 100% 42%;
--border: 240 6% 15%;
}
Every component references these variables through Tailwind's color system. Changing the accent color from cyan to green is a single line change.
The blog system
This was the most interesting architectural decision. The blog supports two content sources that merge transparently:
MDX files for version-controlled content
Blog posts can be written as .mdx files in a content/blog/ directory. Each file has YAML frontmatter for metadata:
---
title: 'Post Title'
date: '2026-02-20'
tags: ['Next.js', 'TypeScript']
featured: true
draft: false
---
MDX files are parsed at build time using gray-matter for frontmatter extraction. The content stays in the Git repository, so every post has full version history.
SQLite for dynamic content
For posts created through the admin API, I use SQLite via better-sqlite3. The schema stores posts, tags, and a junction table:
CREATE TABLE blog_posts (
id INTEGER PRIMARY KEY AUTOINCREMENT,
slug TEXT UNIQUE NOT NULL,
title TEXT NOT NULL,
content TEXT NOT NULL,
featured BOOLEAN DEFAULT FALSE,
published BOOLEAN DEFAULT FALSE,
reading_time INTEGER,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
SQLite is perfect for this use case — it's a single file, needs no server, deploys inside the Docker container, and handles the read-heavy workload of a blog effortlessly.
Merging both sources
The blog loader fetches from both sources and merges them:
export async function getPublishedPosts(): Promise<BlogPost[]> {
const mdxPosts = await getMdxPosts()
const dbPosts = await getDatabasePosts()
return [...mdxPosts, ...dbPosts]
.sort((a, b) => new Date(b.created_at).getTime() - new Date(a.created_at).getTime())
}
Each post carries a source field ('mdx' or 'database') so the API knows which posts can be edited through the admin interface and which are file-based.
Client-side search
The site has a search system that indexes projects, blog posts, and portfolio sections. Instead of reaching for Algolia or ElasticSearch, I built a client-side search with fuzzy matching.
The scoring system weights matches by field importance:
- Title match: 10 points
- Technology/tag match: 8 points
- Description match: 5 points
- Content match: 3 points
For fuzzy matching, I implemented Levenshtein distance calculation so searching "typscript" still finds TypeScript content. The search runs client-side with a 300ms debounce — fast enough that it feels instant, but doesn't fire on every keystroke.
Performance
A few techniques keep the site fast:
Lazy loading with Suspense — The Projects section and Contact Form are loaded with React.lazy() and wrapped in Suspense boundaries with skeleton fallbacks. This keeps the initial bundle small since these components use heavier libraries.
Incremental Static Regeneration — The homepage revalidates every hour (export const revalidate = 3600). Blog pages revalidate on demand. This means the first visitor after a content update sees fresh content, and everyone else gets cached static HTML.
Font optimization — Inter and Space Mono are loaded through next/font/google with display: 'swap' to prevent layout shift. The font-feature-settings enable stylistic alternates for cleaner typography.
Deployment
The site runs in a Docker container deployed via GitHub Actions:
- Push to
mastertriggers the CI workflow - GitHub Actions builds the Next.js app and runs type checks + linting
- The built application is deployed to the server via SSH
- Docker Compose manages the container lifecycle
The workflow includes concurrency control — if you push twice quickly, the first deployment cancels automatically instead of queuing up.
What I'd do differently
Simpler search. The Levenshtein distance calculation and multi-source scoring system works, but a basic String.includes() filter would cover 95% of use cases on a portfolio this size. I over-engineered it.
One blog source. The dual MDX + SQLite system is architecturally interesting, but in practice I only use MDX files. The SQLite path adds complexity for the admin API, authentication, and deployment (persisting the database file). If I started over, I'd pick one.
Less animation. I added staggered fade-in animations to every section. On fast connections, the content loads instantly but still waits for the animation delay. Some animation adds polish; too much adds latency to the reading experience.
The result
The portfolio is live at pratyush.space. The source is structured so adding a new project is a single object in siteConfig.ts, and adding a new blog post is a new .mdx file. That low friction means I actually keep it updated — which is the whole point.
Building your own portfolio from scratch isn't the most efficient path (a template would be faster), but it's a better signal to recruiters than any template could be. It shows you can ship a complete application — routing, data fetching, styling, deployment — not just import components from a library.
Related Posts
Building pyfs-watcher: A Rust-Powered Filesystem Toolkit for Python
How I built a high-performance filesystem toolkit for Python using Rust and PyO3 — parallel directory walking, BLAKE3 hashing, file deduplication, and real-time watching, all from pip install.
React Performance Optimization: A Complete Guide
Master React performance optimization techniques including memo, useMemo, useCallback, code splitting, and more to build blazing-fast applications.
TypeScript Best Practices for Production Applications
Learn essential TypeScript best practices and patterns to write type-safe, maintainable code for production-ready applications.
Enjoyed this post?
Subscribe to get notified when I publish new content about web development and technology.