DEV Community

Cover image for How I Built My Developer Portfolio with Next.js, GSAP, and a 100 SEO Score
suyog bhise
suyog bhise

Posted on • Originally published at suyogbhise.online

How I Built My Developer Portfolio with Next.js, GSAP, and a 100 SEO Score

How I Built My Developer Portfolio with Next.js, GSAP, and a 100 SEO Score

Most developer portfolios look the same. A hero section with a name, some project cards, a contact form. They're functional but forgettable.

When I decided to rebuild my portfolio, I had one rule: it should feel like a product, not a template.

This is the full story of building suyogbhise.online — the architecture decisions, the GSAP animation system, the performance problems I created and then fixed, and the SEO work that got me to a 100 SEO score on PageSpeed Insights.

The Stack

Frontend:  Next.js 16 (App Router) + TypeScript
Styling:   Tailwind CSS + custom CSS
Animation: GSAP 3 + ScrollTrigger + SplitText + Flip
Fonts:     next/font (self-hosted Inter, Space Grotesk, JetBrains Mono)
Images:    AWS S3 + Next.js Image optimization
Email:     Nodemailer (contact form)
Analytics: Vercel Analytics
Deploy:    Vercel
Enter fullscreen mode Exit fullscreen mode

Why Next.js for a Portfolio?

A portfolio could easily be a static HTML/CSS site. I chose Next.js for three reasons:

SEO control. The metadata API, structured data injection, and server-side rendering give me full control over what Google sees. A single-page React app with client-side rendering is harder to get right for SEO — Google can crawl it, but the timing is unreliable.

Image optimization. next/image with S3 remote patterns handles WebP/AVIF conversion, responsive srcsets, and lazy loading automatically. Project screenshots from S3 that were 275KB PNGs get served as 60KB WebP to modern browsers.

The blog. I wanted a blog section with individual pages, proper metadata per post, and URLs like /blog/building-saas-with-nextjs. Next.js App Router makes this natural.

The Design Direction

I went with a dark theme with a yellow-green accent (#e8ff59) for the blog and a blue accent (#3b82f6) for the main portfolio. The aesthetic is developer-minimal — monospace fonts for labels and metadata, clean sans-serif for body text, zero gradients.

Color palette:

:root {
  --bg-primary:   #0a0a0b;  /* near-black background */
  --bg-secondary: #111113;  /* card surfaces */
  --fg-primary:   #fafafa;  /* main text */
  --fg-secondary: #a1a1aa;  /* secondary text */
  --fg-muted:     #71717a;  /* metadata, labels */
  --accent:       #3b82f6;  /* blue — interactive elements */
  --success:      #22c55e;  /* availability badge */
}
Enter fullscreen mode Exit fullscreen mode

Typography:

  • Space Grotesk — display headings (name, section titles)
  • Inter — body text (descriptions, paragraphs)
  • JetBrains Mono — labels, metadata, code snippets

The monospace-for-metadata pattern creates a visual hierarchy without needing different font sizes — the font style itself communicates "this is supporting information."

The Hero Section: Loading Animation

The hero has a multi-stage loading sequence:

  1. A loading counter animates (00 → 100) using GSAP
  2. Project images scale in from the corner with staggered timing
  3. The background slides up
  4. Nav, sidebar, and content fade in
  5. Text lines reveal from bottom using SplitText
// Hero loading sequence
const tl = gsap.timeline({ delay: 4 });

// Background slides up
tl.to(".hero-bg", {
  scaleY: "100%",
  duration: 1.2,
  ease: "power4.inOut",
});

// Images animate to final positions using GSAP Flip
tl.add(() => { animateImages(); }, "-=0.8");

// Counter fades out
tl.to(".counter", { autoAlpha: 0, duration: 0.3 }, "<");

// Nav and sidebar animate in
tl.to(["nav", ".navbar"], {
  y: 0,
  autoAlpha: 1,
  duration: 1,
  ease: "power3.out"
});

// Text lines reveal
tl.to([".name-title span", ".tagline span", ".role-text span"], {
  y: "0%",
  duration: 1,
  stagger: 0.05,
  ease: "power4.out",
}, "<+=0.2");
Enter fullscreen mode Exit fullscreen mode

The SplitText pattern for text reveals:

const setupTextSplitting = () => {
  const elements = document.querySelectorAll(
    ".hero-content h1, .tagline, .role-text"
  );

  elements.forEach((element) => {
    const split = new SplitText(element, {
      type: "lines",
      linesClass: "line",
    });

    // Wrap each line in a span for overflow:hidden clipping
    element.querySelectorAll(".line").forEach((line) => {
      const text = line.textContent || "";
      line.innerHTML = `<span>${text}</span>`;
    });
  });
};
Enter fullscreen mode Exit fullscreen mode

Each line gets wrapped in a parent with overflow: hidden, then the inner span starts at translateY(125%) and animates to 0%. This gives the "text slides up into view" effect without any JavaScript opacity — pure CSS transform animation.

The Project Gallery: GSAP Flip

The hero has a draggable project gallery that starts as stacked images in the corner, then expands horizontally when clicked. This uses GSAP Flip — one of the most underrated GSAP plugins.

Flip captures the current state of elements, changes their layout, then animates from the old state to the new state automatically:

const handleImageClick = () => {
  const images = document.querySelectorAll(".img");

  // Capture current state (stacked in corner)
  const state = Flip.getState(images);

  // Change layout (add class that spreads them horizontally)
  images.forEach((img) => img.classList.add("animate-out"));

  // Animate from old state to new state
  Flip.from(state, {
    duration: 1.5,
    stagger: 0.05,
    ease: "power3.inOut",
  });
};
Enter fullscreen mode Exit fullscreen mode

Without Flip, animating between two completely different layout states requires manually calculating positions and animating each property. Flip does it in 3 lines.

The About Section: Scroll-Driven Text Reveal

The about section has a scroll-driven word-by-word blur reveal. Each word fades from opacity: 0.1, filter: blur(4px) to opacity: 1, filter: blur(0) as you scroll through the section.

// About.tsx
const words = gsap.utils.toArray('.reveal-word');

gsap.to(words, {
  opacity: 1,
  filter: "blur(0px)",
  duration: 1,
  stagger: 0.1,
  ease: "none",
  scrollTrigger: {
    trigger: textContainerRef.current,
    start: "top 85%",
    end: "bottom 55%",
    scrub: 1,      // ties animation to scroll position
  }
});
Enter fullscreen mode Exit fullscreen mode

scrub: 1 smoothly ties the animation progress to scroll position. As you scroll down, more words reveal. Scroll back up, they blur again. The 1 is a lag value — a small delay before the animation catches up to scroll position, making it feel physical rather than mechanical.

Skills: Animated Beam Hierarchy Graph

The skills section uses AnimatedBeam from MagicUI to create a radial hierarchy — a central "SKILLS" node with category nodes in orbit, connected by animated gradient beams, with individual skill icons as leaf nodes.

// Radial positioning math
const getPos = (angleDeg: number, radius: number) => {
  const angleRad = (angleDeg * Math.PI) / 180;
  return {
    top: "50%",
    left: "50%",
    transform: `translate(
      calc(-50% + ${radius * Math.cos(angleRad)}px), 
      calc(-50% + ${radius * Math.sin(angleRad)}px)
    )`,
  };
};

// Each group has an angle, distance from center, and leaf spread
const desktopGroups = [
  { id: "frontend", angle: 220, dist: 220, leafSpread: 100 },
  { id: "backend",  angle: 180, dist: 380, leafSpread: 80  },
  { id: "cloud",    angle: 140, dist: 220, leafSpread: 80  },
  // ...
];
Enter fullscreen mode Exit fullscreen mode

Hovering a skill icon shows a popup card with experience level, description, and tags. On mobile, the radial layout collapses to a vertical accordion — the useWindowWidth hook switches layouts at 768px.

Experience Section: GSAP Cards

The experience cards animate in with rotateX: 8rotateX: 0 as they enter the viewport — a subtle 3D tilt effect on reveal:

gsap.fromTo(
  ".exp-card-wrapper",
  { opacity: 0, y: 80, rotateX: 8 },
  {
    opacity: 1,
    y: 0,
    rotateX: 0,
    duration: 0.9,
    stagger: 0.25,
    ease: "power3.out",
    scrollTrigger: {
      trigger: ".exp-cards-container",
      start: "top 70%",
    },
  }
);
Enter fullscreen mode Exit fullscreen mode

The large background year numbers (2018, 2024, 2025) use a parallax effect — they move upward at a slower rate than scroll, creating depth:

gsap.to(".exp-year-bg", {
  y: -80,
  ease: "none",
  scrollTrigger: {
    trigger: sectionRef.current,
    start: "top bottom",
    end: "bottom top",
    scrub: 1,        // tied to scroll
  },
});
Enter fullscreen mode Exit fullscreen mode

The Contact Form: Nodemailer

The contact form sends emails via a Next.js API route using Nodemailer:

// app/api/contact/route.ts
import nodemailer from 'nodemailer';

export async function POST(req: Request) {
  const { name, email, message } = await req.json();

  const transporter = nodemailer.createTransport({
    service: 'gmail',
    auth: {
      user: process.env.GMAIL_USER,
      pass: process.env.GMAIL_APP_PASSWORD, // App Password, not account password
    },
  });

  await transporter.sendMail({
    from: process.env.GMAIL_USER,
    to: 'suyogb5300@gmail.com',
    replyTo: email,
    subject: `Portfolio contact from ${name}`,
    html: `
      <h3>New message from your portfolio</h3>
      <p><strong>Name:</strong> ${name}</p>
      <p><strong>Email:</strong> ${email}</p>
      <p><strong>Message:</strong></p>
      <p>${message}</p>
    `,
  });

  return Response.json({ message: 'Sent successfully' });
}
Enter fullscreen mode Exit fullscreen mode

Use Gmail App Passwords (not your account password) — generate one at myaccount.google.com/apppasswords.

The Performance Problem I Created

After building everything, I ran PageSpeed for the first time. Mobile: 47. Desktop: 71.

The two main culprits:

1. Google Fonts @import in CSS. I had @import url("https://fonts.googleapis.com/...") in both globals.css and the Hero component's styles.css. Even though I'd set up next/font correctly in layout.tsx, the CSS imports were firing separately and blocking render. The fix: delete every @import from CSS files — next/font handles everything.

2. CLS 0.83 from GSAP inline styles. My components had style={{ opacity: 0 }} as inline styles on animated elements. Inline styles have higher specificity than CSS, so the min-height fixes in my layout CSS were being overridden. Elements were collapsing to 0px before GSAP ran, causing massive layout shift.

The fix: move all initial animation states to gsap.set() inside useEffect, after mount. Let CSS handle reserving space, let GSAP handle animation state.

After both fixes: Performance 91, CLS 0.08.

SEO: Getting to 100

PageSpeed SEO was 100 from the start, but Seobility's audit found issues the Lighthouse SEO score misses:

The H1 problem. My hero <h1> was inside a component loaded with dynamic({ ssr: false }). Googlebot crawls static HTML first — no JavaScript executed. No H1 in static HTML = major SEO penalty.

Fix: add a visually-hidden h1 directly in page.tsx, outside the dynamic import:

// app/page.tsx
export default function Home() {
  return (
    <main>
      {/* Visible to Google, invisible to users */}
      <h1 style={{
        position: 'absolute',
        width: '1px', height: '1px',
        padding: 0, margin: '-1px',
        overflow: 'hidden',
        clip: 'rect(0,0,0,0)',
        whiteSpace: 'nowrap',
        border: 0,
      }}>
        Suyog Bhise — Full Stack Developer | React, Next.js, Node.js
      </h1>

      <Hero /> {/* dynamic, ssr: false */}
      {/* ... */}
    </main>
  );
}
Enter fullscreen mode Exit fullscreen mode

Structured data. I added both Person and WebSite JSON-LD schemas in layout.tsx. The WebSite schema with a SearchAction enables the Google Sitelinks search box:

const websiteJsonLd = {
  "@context": "https://schema.org",
  "@type": "WebSite",
  url: "https://www.suyogbhise.online",
  name: "Suyog Bhise — Full Stack Developer",
  potentialAction: {
    "@type": "SearchAction",
    target: {
      "@type": "EntryPoint",
      urlTemplate: "https://www.suyogbhise.online/blog?q={search_term_string}",
    },
    "query-input": "required name=search_term_string",
  },
};
Enter fullscreen mode Exit fullscreen mode

Meta description length. Mine was 302 characters — too long, getting truncated in search results. Trimmed to 155 characters.

Sitemap. Initially included /#about, /#contact etc. Hash URLs are scroll positions, not pages — Google ignores them and they waste crawl budget. Removed them, keeping only real page URLs.

The Blog System

The blog uses inline React components for content rather than MDX, keeping the dependency list small. Each post is a function component that returns JSX:

function DockerPost() {
  return (
    <>
      <h2>Why Docker for Node.js</h2>
      <p>Before Docker, deploying Node.js meant SSH-ing into...</p>
      <pre data-lang="dockerfile"><code>{`FROM node:20-alpine...`}</code></pre>
    </>
  );
}
Enter fullscreen mode Exit fullscreen mode

Each blog post page uses generateMetadata and generateStaticParams for static generation and proper per-post SEO:

// app/blog/[slug]/page.tsx
export function generateStaticParams() {
  return blogPosts.map((post) => ({ slug: post.slug }));
}

export async function generateMetadata({ params }) {
  const { slug } = await params;
  const post = blogPosts.find((p) => p.slug === slug);

  return {
    title: post.title,
    description: post.excerpt,
    alternates: { canonical: `https://www.suyogbhise.online/blog/${slug}` },
    openGraph: {
      type: "article",
      publishedTime: new Date(post.date).toISOString(),
    },
  };
}
Enter fullscreen mode Exit fullscreen mode

What I'd Do Differently

Start with performance in mind. I retrofitted all the performance optimizations after building. If I'd set up next/font correctly from day one and never used inline style={{ opacity: 0 }} for GSAP initial states, I'd have saved two full days of debugging.

Use gsap.set() for all initial animation states — never inline styles. This is the single most important GSAP lesson from this build. Inline styles cascade incorrectly with CSS, cause hydration warnings, and create CLS issues. gsap.set() in useEffect is always the right approach.

Write the generateMetadata functions at the same time as the pages. I added SEO metadata as a pass after building — it would have been much faster to do alongside each page.

Final Scores

PageSpeed Desktop:
  Performance:    91
  Accessibility:  92
  Best Practices: 96
  SEO:           100

PageSpeed Mobile:
  Performance:    75
  SEO:           100
Enter fullscreen mode Exit fullscreen mode

The mobile performance score is still a work in progress — GSAP animation complexity has a cost on mobile CPUs. The next optimization is reducing the number of scroll-triggered animations on small screens.

The Stack Summary

If you're building a developer portfolio and want this level of polish:

  • Next.js App Router — for SEO control and the blog
  • GSAP — for animations that feel premium, not like CSS transitions
  • next/font — and delete every @import from your CSS files
  • Structured dataPerson + WebSite schemas in layout.tsx
  • generateMetadata — on every page, including blog posts
  • Visually-hidden H1 — if your hero is loaded with ssr: false

The portfolio is open source — check the code at github.com/Suyog5300. Steal what's useful.


I'm Suyog Bhise, a Full Stack Developer based in Pune, India. This portfolio is live at suyogbhise.online.

Top comments (0)