DEV Community

Cover image for πŸš€ How I Built My Developer Portfolio with Next.js, GitHub API, DEV.to API + Trilingual Support and AI Chat in future
Heron Developer
Heron Developer

Posted on

πŸš€ How I Built My Developer Portfolio with Next.js, GitHub API, DEV.to API + Trilingual Support and AI Chat in future

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
  }));
}
Enter fullscreen mode Exit fullscreen mode

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);
}
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

🌍 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;
Enter fullscreen mode Exit fullscreen mode

--

// 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>;
}
Enter fullscreen mode Exit fullscreen mode

Then in any component:

const { lang } = useLang();
const h = translations.hero;

<p>{h.available[lang]}</p> // renders in current language
Enter fullscreen mode Exit fullscreen mode

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!"
}
Enter fullscreen mode Exit fullscreen mode

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 });
}
Enter fullscreen mode Exit fullscreen mode

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
};
Enter fullscreen mode Exit fullscreen mode

⚑ 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,
};
Enter fullscreen mode Exit fullscreen mode

πŸ’‘ Things I Learned (the hard way)

  1. "use client" is not optional for interactive components
    Next.js App Router defaults to Server Components. Any component using useState, useEffect, onMouseEnter etc. 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.

  2. 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.

  3. 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();
Enter fullscreen mode Exit fullscreen mode
  1. 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; }
Enter fullscreen mode Exit fullscreen mode

Then style={{ color: "var(--accent)" }} works everywhere, no config needed.

  1. The {app mystery folder At one point I ended up with a literal folder named {app in 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)