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's
dark: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
Top comments (0)