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