
suyog bhiseA complete walkthrough of building suyogbhise.online — the tech decisions, GSAP animation system, performance optimization journey, and SEO work that got me to 100 SEO score on PageSpeed.
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.
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
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.
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 */
}
Typography:
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 has a multi-stage loading sequence:
// 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");
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>`;
});
});
};
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 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",
});
};
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 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
}
});
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.
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 },
// ...
];
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.
The experience cards animate in with rotateX: 8 → rotateX: 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%",
},
}
);
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
},
});
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' });
}
Use Gmail App Passwords (not your account password) — generate one at myaccount.google.com/apppasswords.
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.
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>
);
}
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",
},
};
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 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>
</>
);
}
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(),
},
};
}
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.
PageSpeed Desktop:
Performance: 91
Accessibility: 92
Best Practices: 96
SEO: 100
PageSpeed Mobile:
Performance: 75
SEO: 100
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.
If you're building a developer portfolio and want this level of polish:
next/font — and delete every @import from your CSS filesPerson + WebSite schemas in layout.tsx
generateMetadata — on every page, including blog postsssr: 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.