Building my developer portfolio
Full-stack portfolio

Why Build a Custom Portfolio?
The short answer: In a world where AI generates code, hiring is broken, and everyone has "Full-Stack Developer" in their title, this portfolio is my differentiator—a real product with real decisions, not another tutorial project.
The longer answer:
1. The AI/LLM Shift
Since ChatGPT, "everyone can code." But the gap between copy-paste prompting and actual engineering understanding matters more than ever. This portfolio demonstrates that I understand why the code works, not just what it does—documented through Architecture Decision Records (ADRs).
2. Global Competition
Remote-first means you're not competing with 50 local developers anymore—you're up against 50,000 worldwide. A standard CV doesn't cut it. You need proof of work.
3. Broken Hiring Processes
LeetCode interviews test algorithms, not whether someone can build a product. This portfolio is better evidence of my capabilities than "reverse a binary tree in 20 minutes."
4. Escaping Tutorial Hell
Many portfolios are recognizably "rebuilt from a YouTube tutorial." This project features custom architecture decisions, self-built solutions, and real trade-offs.
5. "Full-Stack" Inflation
Everyone calls themselves full-stack. But can they implement auth? Generate PDFs? Set up CI/CD? Build responsive layouts with animations? All of it, together, in one coherent system?
What Was Missing From Other Solutions?
Most portfolio templates and builders offer:
Static pages with limited customization
No backend integration
Generic designs that blend in rather than stand out
No way to showcase actual engineering decisions
I needed something that could serve as both a portfolio and proof that I can architect, build, and ship a complete application—frontend to backend, design to deployment.
Tech Stack Decision
Frontend: Nuxt 3 over Next.js
Both are excellent frameworks. I chose Nuxt 3 because:
- Vue's Composition API feels more intuitive than React hooks - less magic, more explicit
- Auto-imports - no more import hell at the top of every file
- Nuxt DevTools - best-in-class debugging experience
- Nitro server engine - hybrid rendering without configuration headaches
Honest take: I'm faster in Vue. In a portfolio project, shipping matters more than framework politics.
Backend: NestJS over Express
Express gives you freedom. Too much freedom. Every Express codebase looks different. NestJS enforces structure:
- Dependency Injection - testable by design, not by accident
- Decorators - clean, readable controllers
- Built-in validation - DTOs with class-validator, no manual checking
- Swagger/OpenAPI - API docs generated from code, always in sync
/ Express: "Where do I put this?"
app.post('/users', validateBody, checkAuth, async (req, res) => { ... })
// NestJS: Structure is obvious
@Post()
@UseGuards(JwtAuthGuard)
createUser(@Body() dto: CreateUserDto) { ... }NestJS taught me patterns I now recognize in Spring Boot and .NET - transferable knowledge.
Architecture Highlights
ADRs as Decision Foundation
Every significant choice in this project is documented—not as an afterthought, but as a thinking tool. Before writing code, I write an ADR.
markdown
# ADR-001: Frontend Framework Selection
## Context
Need a modern framework that supports SSR, TypeScript, and rapid development.
## Alternatives Considered
| Option | Pros | Cons |
|-------------|----------------------|-------------------------|
| Next.js | Larger ecosystem | React learning curve |
| Nuxt 3 ✅ | Intuitive, auto-imports | Smaller ecosystem |
| Astro | Great for static | Less dynamic capability |
## Decision
Nuxt 3 with TypeScript
## Consequences
- ✅ Type safety across full stack
- ✅ SSR for SEO out of the box
- ⚠️ Team onboarding requires Vue + Nuxt knowledge
```
This forces deliberate thinking before coding—and creates documentation that actually stays useful.
---
### Project Structure
A new developer should immediately know where to look. No guessing, no "where does this go?"
```
api/
├── src/
│ ├── auth/ # JWT, Guards, Strategies
│ ├── blog/ # Blog CRUD + DTOs
│ ├── cv/ # CV data + PDF generation
│ ├── media/ # File uploads
│ └── common/ # Shared filters, pipes, logger
frontend/
├── components/ # Reusable UI
├── composables/ # Business logic (useAuth, useApi, useAnimations)
├── pages/ # File-based routing
└── layouts/ # Page templatesEach module owns its domain. Dependencies flow inward, not sideways.
Interesting Features
CV PDF Generator
The hardest feature. Generating a pixel-perfect A4 PDF from dynamic data sounds simple—until you realize: A4 pages don't scroll.
typescript
// Shared browser instance - reused across requests for performance
private async getBrowser(): Promise<Browser> {
if (this.browserInstance?.connected) {
return this.browserInstance;
}
return this.launchBrowser();
}
// Dynamic page splitting based on content height
private splitExperiencesIntoPages(experiences: Experience[]): Experience[][] {
const PAGE_1_CAPACITY = 9.5; // Less space (header + sidebar)
const PAGE_N_CAPACITY = 12.0; // Full height on continuation pages
// ... splits content to prevent overflow
}The challenge: Content must fit or split intelligently across pages. No CSS overflow tricks here—each page is rendered separately.
GSAP Animations
Animations should feel natural—triggered when visible, not dumped on page load.
typescript
// Staggered entrance animation
gsap.to('.cv-job-card', {
duration: 1.2,
opacity: 1,
y: 0,
ease: 'power3.out',
stagger: 0.1 // Each card appears 100ms after the previous
});
// Animate only when element enters viewport
const observer = new IntersectionObserver((entries) => {
entries.forEach(entry => {
if (entry.isIntersecting) {
animateEntrance();
observer.disconnect(); // Fire once, then cleanup
}
});
}, { threshold: 0.15 });Why Intersection Observer? Scroll-based triggers without scroll event listeners. Better performance, cleaner code.
Lofi Media Player
A persistent audio player that survives page navigation—global state without the ceremony of Vuex or Pinia.
typescript
// Global state lives outside the composable
const globalState = {
isPlaying: ref(false),
currentStation: ref(0),
audioPlayer: null as HTMLAudioElement | null,
};
// Composable exposes reactive refs with controlled access
export const useLofiPlayer = () => {
return {
isPlaying: readonly(globalState.isPlaying),
togglePlay,
nextStation,
};
};Sometimes the simplest solution is the right one. No store, no actions, no mutations—just refs that persist across the app lifecycle.