Tech Stack
- Frontend: Next.js 16 (App Router), React 19, TypeScript
- Styling: Tailwind CSS + Custom CSS
- Backend: PocketBase (self-hosted on Proxmox)
- Analytics: PostHog
- Deployment: GitLab CI/CD → Vercel
Self-Hosted PocketBase Backend
The entire backend runs on a self-hosted PocketBase instance on my Proxmox homelab. This gives me full control over the data and API. PocketBase provides:
- SQLite database with a REST API
- Admin dashboard for content management
- User authentication for API access
- File storage for images
Posts are fetched server-side with Incremental Static Regeneration (ISR), so pages are fast but content stays fresh:
const posts = await pb.collection('posts').getList(1, 20, {
filter: 'status="published"',
sort: '-published_at',
expand: 'tags'
});
Tag System with Categories
Tags are stored in PocketBase with a category field for organization. Each tag has a name, slug, color, and category (language, framework, service, platform, infrastructure, or concept).
interface Tag {
id: string;
name: string;
slug: string;
color: string;
category: 'language' | 'framework' | 'service' | 'platform' | 'infrastructure' | 'concept';
}
Tags display sorted by category so languages appear together, frameworks together, etc:
const sortedTags = [...tags].sort((a, b) => {
const order = ['language', 'framework', 'service', 'platform', 'infrastructure', 'concept'];
return order.indexOf(a.category) - order.indexOf(b.category);
});
Morphing Profile Image
The hero section features an organic wobbling effect on the profile image using CSS border-radius animation:
.transform-banner img {
animation: border-transform 10s linear infinite alternate;
border-radius: 100%;
}
@keyframes border-transform {
0%, 100% { border-radius: 63% 37% 54% 46%/55% 48% 52% 45%; }
14% { border-radius: 40% 60% 54% 46%/49% 60% 40% 51%; }
28% { border-radius: 54% 46% 38% 62%/49% 70% 30% 51%; }
42% { border-radius: 61% 39% 55% 45%/61% 38% 62% 39%; }
56% { border-radius: 61% 39% 67% 33%/70% 50% 50% 30%; }
70% { border-radius: 50% 50% 34% 66%/56% 68% 32% 44%; }
84% { border-radius: 46% 54% 50% 50%/35% 61% 39% 65%; }
}
Here it is in action:
Confetti Celebration
The "Hire Me" button triggers a confetti animation using the canvas-confetti library:
import confetti from 'canvas-confetti';
const handleHireClick = () => {
confetti({
particleCount: 100,
spread: 70,
origin: { y: 0.6 }
});
};
Dynamic Text Contrast
Tag backgrounds come from PocketBase. Text color is calculated automatically based on luminance for accessibility:
function getContrastTextColor(hexColor: string): string {
const hex = hexColor.replace('#', '');
const r = parseInt(hex.substring(0, 2), 16);
const g = parseInt(hex.substring(2, 4), 16);
const b = parseInt(hex.substring(4, 6), 16);
const luminance = (0.299 * r + 0.587 * g + 0.114 * b) / 255;
return luminance > 0.5 ? '#1a1a2e' : '#fff';
}
CI/CD Pipeline
The repo lives on a self-hosted GitLab instance. Pushes trigger automated linting, type checking, and deployment to Vercel:
stages:
- lint
- deploy
lint:
script:
- pnpm install
- pnpm lint
- pnpm build
deploy:
script:
- vercel --prod --token $VERCEL_TOKEN
