Who’s me?
I’m a self-taught developer still learning, and a few weeks ago I decided it was time to stop putting it off and actually build my portfolio.
💡 Inspired by: Portfolio with Gemini — Simple, Smooth, Subtle by @aldwin160. That post showed me how to approach the AI chat feature with a good system prompt. Highly recommend reading it.
Why I built this?
I had been studying web development for some time, and noticed that many developers had already built their portfolio models, so based on that, I decided to make mine, a complete portfolio of information for those who understand it. With the help of Next.js and Tailwind CSS frameworks with applications: API integration, server-side routing, TypeScript, animations, i18n.
✨ Features
Before diving into the code, here’s what the final portfolio does:
🌍 Trilingual ── PT 🇧🇷 / EN 🇺🇸 / ES 🇪🇸 with a navbar language switcher (saved in localStorage)
🌙 Dark / Light mode toggle
🐙 GitHub API ── live repos, commit count, follower count in the Hero section
✍️ DEV.to API ── posts fetched automatically, zero token needed
📬 Contact form via EmailJS (no backend needed)
🤖 Chat Assistant ── smart static responses in 3 languages, with Gemini AI ready to activate
📱 Fully responsive ── built mobile-first with inline styles + CSS variables
🛠️ Tech Stack & Why I Chose Each
Tool and Why
1. Next.js 14 (App Router) ── Server components, API routes, file-based routing — all in one
2. TypeScript ── Caught so many bugs at compile time instead of runtime
3. Tailwind CSS ── Utility classes are fast, but I ended up using CSS variables + inline styles for reliability
4. Framer Motion ── The animation presets made scroll animations trivial
5. GitHub API ── Free, well-documented, no special setup for public repos
6. DEV.to API ── Completely public — no token, no account needed to read posts
7. EmailJS ── Contact form without needing my own email server
8. Google Gemini ── Free tier, easy API — integrated but commented for now (rate limits)
📁 Project Structure
porfolioFull/
src/
├── app/
│ ├── globals.css
│ ├── layout.tsx # fonts, metadata, providers
│ ├── page.tsx # mounts all sections
│ ├── favicon.ico
│ └── api/
│ ├── github/
│ │ └── route.ts # server-side proxy (hides token)
│ ├── contact/
│ │ └── route.ts # EmailJS server-side
│ └── chat/
│ └── route.ts # Gemini API route (ready to activate)
├── components/
│ ├── sections/ # one file per page section
│ │ ├── Hero.tsx
│ │ ├── About.tsx
│ │ ├── Skills.tsx
│ │ ├── Projects.tsx
│ │ ├── Posts.tsx
│ │ └── Contact.tsx
│ ├── Navbar.tsx # with language switcher dropdown
│ ├── Footer.tsx
│ ├── ChatWidget.tsx # floating AI chat
│ └── ThemeProvider.tsx # dark/light context
├── data/
│ ├── portfolio.ts # ← only file you edit: your info, skills, projects
│ ├── i18n.ts # all PT/EN/ES translations
│ ├── LangContext.tsx # language context + localStorage
│ └── staticChat.ts # static chat responses by language
├── hooks/
│ ├── useGitHub.ts # fetches profile, repos, commits
│ └── useDevTo.ts # fetches your DEV.to posts
├── lib/
│ ├── github.ts # GitHub API functions
│ ├── devto.ts # DEV.to API functions
│ └── motion.ts # Framer Motion
│ └── utils.ts
└── types/
│ └── index.ts # TypeScript interfaces
└── etc/
│ └── .env.local #env
│ └── .gitignore
│ └── .components.json
│ └── eslint.config.mjs
│ └── next-env.d.ts
│ └── next.config.ts
│ └── package-lock.json
│ └── package.json
│ └── postcss.config.mjs
│ └── README.md
│ └── tailwind.config.ts
│ └── tsconfig.json
The separation makes sense once you understand the roles:
-
sections/— page UI, one component per visual section -
data/— your content and translations, no UI logic -
lib/— pure functions that talk to external APIs -
hooks/— client-side data fetching with loading/error states -
api/— server-side routes that hide secret tokens from the browser
🐙 Integrating GitHub & DEV.to APIs
DEV.to — Zero config
The DEV.to API is completely public for reading articles. No token, no account, just fetch:
// src/lib/devto.ts
export async function getDevToPosts(username: string): Promise<DevToPost[]> {
const res = await fetch(
`https://dev.to/api/articles?username=${username}&per_page=10`,
{ next: { revalidate: 1800 } } // Next.js cache: 30min
);
if (!res.ok) return [];
const posts: DevToApiPost[] = await res.json();
return posts.map((p): DevToPost => ({
id: p.id,
title: p.title,
url: p.url,
tags: p.tag_list || [],
date: p.published_at,
// ... rest of mapping
}));
}
GitHub — Hiding the token
The GitHub API works without a token for public repos, but you hit rate limits fast (60 req/hour unauthenticated vs 5,000 with a token). The trick: never expose the token to the browser. Use a Next.js API route as a server-side proxy:
// src/app/api/github/route.ts — runs on the server, token stays secret
import { NextRequest, NextResponse } from "next/server";
export async function GET(request: NextRequest) {
const endpoint = request.nextUrl.searchParams.get("endpoint");
const res = await fetch(`https://api.github.com${endpoint}`, {
headers: {
Authorization: `Bearer ${process.env.GITHUB_TOKEN}`, // server-only!
Accept: "application/vnd.github.v3+json",
},
});
const data = await res.json();
return NextResponse.json(data);
}
Then the client hook calls
/api/github?endpoint=/users/username— it never sees the token.
Filtering and sorting repos
// src/lib/github.ts
export async function getGitHubRepos(username: string): Promise<GitHubRepo[]> {
const res = await fetch(
`https://api.github.com/users/${username}/repos?sort=updated&per_page=20`,
{ next: { revalidate: 3600 } }
);
const repos: GitHubRepo[] = await res.json();
return repos
.filter((r) => !r.name.includes("fork")) // no forks
.sort((a, b) => b.stargazers_count - a.stargazers_count) // best first
.slice(0, 6); // top 6 only
}
🌍 Trilingual Support (PT / EN / ES)
This was something I added because I want to reach developers from different countries and also practice thinking in English and Spanish. The approach was simple: one big translations object and a React context.
// src/data/i18n.ts (simplified)
export const translations = {
hero: {
available: {
pt: "disponível para oportunidades",
en: "open to opportunities",
es: "disponible para oportunidades",
},
// ...
},
// all sections follow the same pattern
} as const;
—
// src/data/LangContext.tsx
export function LangProvider({ children }: { children: React.ReactNode }) {
const [lang, setLangState] = useState<Lang>("pt");
useEffect(() => {
const saved = localStorage.getItem("lang") as Lang | null;
if (saved) setLangState(saved);
}, []);
const setLang = (l: Lang) => {
setLangState(l);
localStorage.setItem("lang", l); // persists across refreshes
};
return <LangCtx.Provider value={{ lang, setLang }}>{children}</LangCtx.Provider>;
}
Then in any component:
const { lang } = useLang();
const h = translations.hero;
<p>{h.available[lang]}</p> // renders in current language
The navbar has a dropdown with flag emojis 🇧🇷 🇺🇸 🇪🇸 — switching language re-renders everything instantly.
🤖 The Chat Assistant
!(You can apply chatting with Google AI Studio in the future)!
This was the most fun part to build. The idea came from this post by @aldwin160 — using an AI chat as a creative way for recruiters to learn about you.
Current state: static responses
I integrated Google Gemini but hit the free tier quota limit (limit: 0 error — the key was linked to a project without the free quota). Rather than block the feature entirely, I built a static response engine that covers the most common recruiter questions:
// src/data/staticChat.ts
const STATIC_RESPONSES = [
{
keywords: ["stack", "technology", "tecnologia", "tecnología"],
answer: {
pt: "Minha stack principal é **Next.js**, **React**, **TypeScript**...",
en: "My main stack is **Next.js**, **React**, **TypeScript**...",
es: "Mi stack principal es **Next.js**, **React**, **TypeScript**...",
},
},
// available, projects, experience, contact, learning...
];
export function getStaticReply(input: string, lang: Lang): string {
const lower = input.toLowerCase();
for (const item of STATIC_RESPONSES) {
if (item.keywords.some((kw) => lower.includes(kw))) {
return item.answer[lang];
}
}
return FALLBACK[lang]; // fallback: "contact me directly!"
}
Gemini AI — ready to activate
The full Gemini integration is built and commented in ChatWidget.tsx. When I get a proper API key it’s literally uncommenting one function:
// src/app/api/chat/route.ts — server-side to avoid CORS
export async function POST(request: NextRequest) {
const { messages, systemPrompt } = await request.json();
const key = process.env.NEXT_PUBLIC_GEMINI_KEY;
const res = await fetch(
`https://generativelanguage.googleapis.com/v1beta/models/gemini-2.0-flash:generateContent?key=${key}`,
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
system_instruction: { parts: [{ text: systemPrompt }] },
contents: messages,
}),
}
);
const data = await res.json();
const text = data.candidates?.[0]?.content?.parts?.[0]?.text ?? "No response.";
return NextResponse.json({ text });
}
Important: The Gemini call goes through a Next.js API route, not directly from the browser. This avoids CORS errors and keeps the key server-side.
UX detail: the menu system
After every response, the chat shows the 3 quick-question buttons again plus a "↩ Back to main menu" button. This keeps the conversation flowing even for non-technical visitors who might not know what to ask:
// After each reply, showMenu state triggers the suggestion buttons
const send = (text: string) => {
const reply = getStaticReply(text, lang);
setMsgs(p => [...p,
{ role: "user", content: text },
{ role: "model", content: reply },
]);
setShowMenu(true); // always show menu after response
};
⚡ Animation Presets with Framer Motion
typescript
Instead of writing the same animation object in every component, I extracted presets:
// src/lib/motion.ts
import type { Transition } from "framer-motion";
export const fadeInUp = {
initial: { opacity: 0, y: 24 },
whileInView: { opacity: 1, y: 0 },
viewport: { once: true },
transition: { duration: 0.5, ease: "easeOut" } as Transition,
};
export const stagger = (i: number) => ({
initial: { opacity: 0, y: 20 },
whileInView: { opacity: 1, y: 0 },
viewport: { once: true },
transition: { duration: 0.4, delay: i * 0.08, ease: "easeOut" } as Transition,
});
// Float animation for the chat FAB button
export const floatAnimate = {
y: [0, -8, 0],
transition: { duration: 3, repeat: Infinity, ease: "easeInOut" } as Transition,
};
💡 Things I Learned (the hard way)
-
"use client"is not optional for interactive components
Next.js App Router defaults to Server Components. Any component usinguseState,useEffect,onMouseEnteretc. needs"use client"at the top. I spent an embarrassing amount of time debugging this error:Error: Event handlers cannot be passed to Client Component props.
-
Never call external APIs directly from the browser
The Gemini API blocks browser requests with CORS. The GitHub token would be visible in network tab. Always proxy through a Next.js API route. -
Type your API responses, don’t use
any
// ❌ This will bite you later
const posts: any[] = await res.json();
// ✅ This catches bugs immediately
interface DevToApiPost { id: number; title: string; tag_list: string[]; ... }
const posts: DevToApiPost[] = await res.json();
- CSS Variables beat Tailwind for theming
For dark/light mode with dynamic values, CSS variables are much cleaner than Tailwind’sdark:classes:
:root { --accent: #16a34a; --bg: #f4f4f8; }
[data-theme="dark"]{ --accent: #7fffb2; --bg: #090910; }
Then style={{ color: "var(--accent)" }} works everywhere, no config needed.
- The
{appmystery folder
At one point I ended up with a literal folder named{appin my project because of a bad bash command. Always double-check your shell scripts.
🔗 Final links
- 🌐 Portfolio: portfoliodevheron.xyz
- 🐙 GitHub repo: github.com/devheron/portfolioFull
- 💬 Inspiration: Portfolio with Gemini by @aldwin160