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
- TypeScript — typed throughout, including the bilingual content
- Tailwind CSS v4 — CSS-first config, custom dark-mode variant instead of a plain media query
- Framer Motion — scroll animations, micro-interactions
- Resend — transactional email from the contact form
- lucide-react — icon set
- Vercel + GitHub — CI/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.
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>.
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:
@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.
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"; // fixed5. 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.
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.