← All projects

Case Study

Portfolio Website

This site is itself my most recent project: a bilingual Next.js application with a custom theme and language system, animated sections, and a real backend behind the contact form. Here's how it's built.

Overview

The goal was a fast, clean portfolio that shows my path from IT support toward cloud/Azure — no website builder, fully hand-coded. Beyond design and content, I focused on three technical aspects: clean DE/EN bilingual support without an external i18n library, a custom, predictable dark-mode system, and a real, working contact form instead of a simple mailto link.

Tech stack

  • Next.js 16 (App Router)React framework, server & client components, route handlers as a lightweight backend
  • TypeScripttyped throughout, including the bilingual content
  • Tailwind CSS v4CSS-first config, custom dark-mode variant instead of a plain media query
  • Framer Motionscroll animations, micro-interactions
  • Resendtransactional email from the contact form
  • lucide-reacticon set
  • Vercel + GitHubCI/CD: every push to main deploys automatically

Under the hood

1. Bilingual content without an i18n library

Instead of react-i18next or similar, there's a simple Localized = { de: string; en: string } type, backed by a LanguageProvider that exposes the current value via React Context. Every component just reads content[lang] — for two languages and a portfolio-sized scope, that's fully sufficient without an extra dependency.

data/content.ts
export type Localized = Record<"de" | "en", string>;

export const tagline: Localized = {
  de: "Ich sorge für stabile IT-Umgebungen …",
  en: "I keep IT environments running smoothly …",
};

// Consumed in any component:
const { lang } = useLanguage();
<p>{tagline[lang]}</p>

2. A theme system that starts predictably

One requirement was that the site should always start in light mode and English on load, regardless of what was set last. Libraries like next-themes persist the choice in localStorage by default. Rather than fighting that, a small, deliberately state-only context was faster and clearer: no localStorage, state always starts at the intended default, and it's driven purely by a .dark class on <html>.

components/ThemeProvider.tsx
const [theme, setTheme] = useState<"light" | "dark">("light");

useEffect(() => {
  document.documentElement.classList.toggle("dark", theme === "dark");
}, [theme]);

In Tailwind v4, the dark: variant can be redefined to react to that class instead of prefers-color-scheme:

app/globals.css
@custom-variant dark (&:is(.dark *));

3. A real backend for the contact form

Instead of a mailto link, the contact form goes through a Next.js route handler at /api/contact. It validates input server-side, blocks simple bots with a honeypot field, and sends the message via Resend — including reply-to set to the sender's address so I can reply directly.

app/api/contact/route.ts
export async function POST(request: Request) {
  const { name, email, message, company } = await request.json();

  // Honeypot: real visitors never fill this in, bots often do.
  if (company) return NextResponse.json({ ok: true });

  if (!name?.trim() || !email?.trim() || !message?.trim()) {
    return NextResponse.json({ error: "Please fill in all fields." }, { status: 400 });
  }

  const resend = new Resend(process.env.RESEND_API_KEY);
  const { error } = await resend.emails.send({
    from: "Portfolio <onboarding@resend.dev>",
    to: siteConfig.email,
    replyTo: email,
    subject: `Portfolio contact from ${name}`,
    text: message,
  });

  if (error) return NextResponse.json({ error: "Failed to send." }, { status: 502 });
  return NextResponse.json({ ok: true });
}

4. A production bug worth telling

After deploying, test emails never arrived — yet the API kept reporting ok: true. The cause: the Resend client doesn't throw on a rejected send, it resolves with { data, error }. Without checking that error field, a failure was silently reported as success. Once that was fixed, the real bug surfaced: an environment variable set to an empty string (CONTACT_FROM_EMAIL="") produced an invalid sender address — ?? doesn't catch that, since an empty string isn't null/undefined. The fix was .trim() || fallback instead of ??.

// Before: silently "succeeds" even when Resend rejects the send
await resend.emails.send({ from, to, subject, text });
return NextResponse.json({ ok: true });

// After: the error field is actually checked
const { error } = await resend.emails.send({ from, to, subject, text });
if (error) return NextResponse.json({ error: "Failed to send." }, { status: 502 });

// And the empty-string env var trap:
const from = process.env.CONTACT_FROM_EMAIL ?? "onboarding@resend.dev"; // bug: "" is not caught
const from = process.env.CONTACT_FROM_EMAIL?.trim() || "onboarding@resend.dev"; // fixed

5. Scroll animations via one reusable component

Rather than repeating Framer Motion props in every section, there's a single FadeIn wrapper using whileInView. Every section on the page uses this one component — consistent animation, one place to tune it.

components/FadeIn.tsx
export default function FadeIn({ children, delay = 0 }: { children: ReactNode; delay?: number }) {
  return (
    <motion.div
      initial={{ opacity: 0, y: 16 }}
      whileInView={{ opacity: 1, y: 0 }}
      viewport={{ once: true, margin: "-80px" }}
      transition={{ duration: 0.5, delay, ease: "easeOut" }}
    >
      {children}
    </motion.div>
  );
}

Deployment

The code lives on GitHub; Vercel is connected directly to the repository, so every push to main automatically triggers a new production build. The custom domain is registered with All-Inkl, with A and CNAME records pointing to Vercel's edge network, including an automatically issued SSL certificate.

Interested in working together?

Go to the contact form →