โ† Back to Blog

Building My Portfolio with Next.js 14

ยท5 min read
next.jsreacttypescriptportfolioposthogupstash

Building My Portfolio with Next.js 14

When I decided to rebuild my portfolio, I wanted to use the latest technologies while keeping things simple and maintainable. Here's a detailed look at how I built it.

Tech Stack Overview

The portfolio is built with:

  • Next.js 14 with App Router for server components and routing
  • TypeScript for type safety
  • CSS Modules for scoped styling
  • MDX for this blog you're reading right now
  • PostHog for analytics and event tracking
  • Upstash Redis for article likes and view counts

Project Structure

Here's how I organized the codebase:

src/
โ”œโ”€โ”€ app/           # Next.js App Router pages
โ”‚   โ””โ”€โ”€ api/       # API routes (likes, views)
โ”œโ”€โ”€ components/    # Reusable UI components
โ”œโ”€โ”€ content/       # MDX blog posts
โ”œโ”€โ”€ images/        # Static images
โ””โ”€โ”€ lib/           # Utility functions (blog, redis)

Each component follows a consistent pattern:

// ComponentName/
//   โ”œโ”€โ”€ ComponentName.tsx
//   โ”œโ”€โ”€ ComponentName.module.css
//   โ””โ”€โ”€ index.ts
 
import styles from "./ComponentName.module.css";
 
interface Props {
  children: React.ReactNode;
  variant?: "primary" | "secondary";
}
 
export function ComponentName({ children, variant = "primary" }: Props) {
  return (
    <div className={styles[variant]}>
      {children}
    </div>
  );
}

Design System

I created a simple design system using CSS custom properties:

:root {
  /* Neutral colors */
  --N0: #ffffff;
  --N50: #f8f9fa;
  --N100: #e9ecef;
  --N900: #212529;
 
  /* Accent colors */
  --B300: #3b82f6;  /* Blue */
  --G300: #22c55e;  /* Green */
  --R300: #ff5630;  /* Red - for likes */
  --Y300: #eab308;  /* Yellow */
}

Adding the Blog

The blog feature uses MDX with next-mdx-remote. Here's the setup:

// lib/blog.ts
import matter from "gray-matter";
import readingTime from "reading-time";
 
export function getPostBySlug(slug: string) {
  const filePath = path.join(BLOG_DIR, `${slug}.mdx`);
  const fileContents = fs.readFileSync(filePath, "utf8");
  const { data, content } = matter(fileContents);
 
  return {
    ...data,
    content,
    readingTime: readingTime(content).text,
  };
}

Custom MDX Components

I created custom components specifically for the blog:

  • ArticleImage โ€” Enhanced images with zoom and fullwidth support
  • Callout โ€” Highlighted tip/info/warning boxes
  • CodeBlock โ€” Syntax-highlighted code with copy button
  • LinkCard โ€” Rich link previews
  • Mention โ€” Social media mentions with platform icons
  • LikeButton โ€” Interactive like button with PostHog tracking
  • ViewCounter โ€” Page view counter

Article Engagement Features

Each article includes a footer with engagement features:

Like Button

The like button uses Upstash Redis for persistent storage and PostHog for analytics:

// LikeButton.tsx
const handleLike = async () => {
  if (hasLiked) return;
 
  setHasLiked(true);
  localStorage.setItem(`liked:${slug}`, 'true');
 
  // Track in PostHog
  posthog.capture('article_liked', { slug });
 
  // Persist to Upstash
  await fetch('/api/likes', {
    method: 'POST',
    body: JSON.stringify({ slug }),
  });
};

View Counter

Views are tracked automatically when users visit an article:

// ViewCounter.tsx
useEffect(() => {
  const hasViewed = sessionStorage.getItem(`viewed:${slug}`);
 
  if (!hasViewed) {
    fetch('/api/views', {
      method: 'POST',
      body: JSON.stringify({ slug }),
    }).then(() => {
      sessionStorage.setItem(`viewed:${slug}`, 'true');
    });
  }
}, [slug]);

Analytics with PostHog

PostHog provides insight into how users interact with the site:

// providers.tsx
'use client';
import posthog from 'posthog-js';
import { PostHogProvider } from 'posthog-js/react';
 
if (typeof window !== 'undefined') {
  posthog.init(process.env.NEXT_PUBLIC_POSTHOG_KEY!, {
    api_host: process.env.NEXT_PUBLIC_POSTHOG_HOST,
  });
}
 
export function PHProvider({ children }: { children: React.ReactNode }) {
  return <PostHogProvider client={posthog}>{children}</PostHogProvider>;
}

Upstash Redis Integration

For serverless-friendly data persistence, I use Upstash Redis:

// lib/redis.ts
import { Redis } from '@upstash/redis';
 
export const redis = new Redis({
  url: process.env.UPSTASH_REDIS_REST_URL!,
  token: process.env.UPSTASH_REDIS_REST_TOKEN!,
});

The API routes are simple and efficient:

// app/api/likes/route.ts
export async function POST(request: NextRequest) {
  const { slug } = await request.json();
  const likes = await redis.incr(`likes:${slug}`);
  return NextResponse.json({ likes });
}

Performance Optimizations

Here are some optimizations I implemented:

  1. Static Generation โ€” All pages are pre-rendered at build time
  2. Image Optimization โ€” Using Next.js Image with blur placeholders
  3. Font Optimization โ€” Using the Geist font with next/font
  4. Code Splitting โ€” Components are loaded only when needed
  5. Optimistic UI โ€” Like and view counts update immediately

Useful Resources

If you're building your own portfolio, check out these resources:

Next.js Documentationnextjs.org
Upstash Redisupstash.com
PostHog Documentationposthog.com

Conclusion

Building this portfolio was a great learning experience. The combination of Next.js 14, TypeScript, CSS Modules, PostHog, and Upstash provides a solid foundation that's easy to maintain and extend.

The engagement features (likes and views) add a nice interactive element while providing valuable insights into which content resonates with readers.

If you're interested in the source code, it's available on @a1exalexander.