The 2nd iteration of my developer portfolio documenting my journey, challenges faced, lessons learned, and growth as a software engineer, built with Next.js, Framer Motion, and Sanity CMS.
Picture this: a developer, fueled by caffeine and ambition, decides to revamp his portfolio. Sounds like the beginning of a tech joke, right? Well, grab your popcorn (or your preferred debugging snack), because you're about to witness the thrilling saga of how I transformed my digital calling card from a static snoozefest to a dynamic masterpiece. And yes, I'm fully aware of the irony of reading about the creation of v2 on v2 itself. It's like Inception, but with more CSS and fewer Leonardo DiCaprios.
Once upon a time, in a galaxy not so far away (aka my university days), I crafted what I thought was the Mona Lisa of portfolios. Spoiler alert: it wasn't. Built with the now-infamous create-react-app, v1 was a single-page React app that screamed, "I just learned React, and I must show it to the world!"
If you're in the mood for some good old-fashioned cringe, you're welcome to check it out here. Yes, it's still online, and no, I’m not proud of it. But hey, we all need a reminder of how bad things once were to appreciate the present, right? It's like those moments right before you sleep, when your brain decides to remind you of something so cringeworthy from your past that you wish the earth would swallow you whole. You’ll cringe, but you’ll also see how far you’ve come.
Oh, create-react-app, how you betrayed me. What once seemed like a magic wand for spawning React projects turned out to be more like a monkey's paw. Here's what I got for my wish:
As time went on, my skills grew faster than my portfolio could keep up. Looking at v1 became like stumbling upon old Facebook (or should I say Meta) photos—you know the ones, with the questionable hairstyles and even more questionable fashion choices. It was a cringeworthy reminder of past design decisions that seemed brilliant at the time.
And yet, I’ve left it there, like a digital fossil, as a reminder of my humble beginnings. Every time I visit it, I remember: progress isn't about perfection, it's about moving forward.
Enter Emmanuel Gatwech better known as Eman, my brilliantly annoying friend and fellow software engineer. His constant nagging about documentation was like a persistent pop-up ad for personal growth—annoying, but impossible to ignore forever.
Emmanuel's incessant preaching about the virtues of blogging and documenting personal experiences planted a seed in my mind. What if my portfolio wasn't just a showcase, but a chronicle of my developer journey? A place where each project came with its own epic saga of triumphs and face-palms?
This idea came with an unexpected bonus: a built-in BS detector for my projects. If a project wasn't blog-worthy, did it deserve a spot in my portfolio? Suddenly, I had a quality threshold that would make any perfectionist proud.
Now, here's where I make a confession that would make my past self gasp: v2 of my portfolio doesn't quite meet the criteria I set for including projects. It's like I'm the developer version of those "Do as I say, not as I do" parents. But hey, if we can't bend the rules for ourselves, what's the point of making them, right?😌
For v2, I decided to stick with technologies I knew well but push them to their limits. It's like using a familiar recipe but adding exotic spices:
Designing a portfolio is like trying to fit your entire personality into a single outfit – it's not easy, and there's always the risk of looking like you tried too hard. After scouring the internet for inspiration and trying out designs that felt more forced than a sitcom laugh track, I stumbled upon Praha's Framer template. It was love at first sight (with a few tweaks, of course).
The goal was to create a UI that would make recruiters say "Wow!" and a UX that would make them say "That was easy!" I focused on stripping down the unnecessary fluff, prioritizing what recruiters and potential clients would want to see. The result? A sleek, dark-mode design with a single accent color that screamed "I'm professional, but I also know how to party (with code)."
In v1, I was proud of my custom hook using the Intersection Observer API for scroll animations. Figuring out how those smooth animations worked online felt like unlocking a secret. Without relying on external tools, I built everything using the native browser API. The process was more complex than standard CSS animations, but managing each element manually with JavaScript taught me a lot about control and precision.
But v2? Oh, it's a whole new ballgame. Enter Framer Motion, the superhero of the animation world. Now, I’ve moved from manually crafting animations to effortlessly making them come to life. It's like going from flip-book animation to Disney-level smoothness overnight.
And let's not forget the pièce de résistance—the box reveal animation:
This little number turns image loading from a boring wait into a mesmerizing reveal. It's like those fancy scratch-off lottery tickets, but instead of disappointment, you get eye candy every time.
Now, buckle up, because we're about to dive into the treacherous waters of page transitions. Remember when I said Framer Motion was the superhero of the animation world? Well, even superheroes have their kryptonite, and for Framer Motion, it was Next.js's App Router.
Picture this: you're cruising along, building your portfolio, feeling like the Tony Stark of web development. Then suddenly, you hit a wall. Implementing smooth page transitions with Framer Motion in Next.js's App Router turned out to be about as straightforward as teaching a cat to fetch while blindfolded. There's an open issue that's been haunting developers for nearly two years, turning what should be a simple animation into a Herculean task.
But fear not, dear reader, for every bug is just an opportunity for a developer to flex their problem-solving muscles! After diving deep into the GitHub issue trenches, I emerged with a solution. The key? A "freeze fix" that stops the unmount to allow the exit animation to work normally before updating the page. Sounds simple, right? Well, as any developer knows, solving one problem often leads to two more (it's like the hydra of the coding world).
First, my project filter suddenly decided to take an unscheduled vacation. Turns out, the animation key was only considering the pathname, completely ignoring the search params. The fix? Create a key that combines both the pathname and search params. Voila! The project filter was back in business.
And just when I thought I was out of the woods, Next.js threw another curveball. Apparently, using the useSearchParams hook is like handling an infinity stone - it needs to be wrapped carefully. Enter the Suspense component, swooping in like a safety net for our volatile hook.
In the end, was it worth it? Absolutely. The portfolio now transitions between pages with the smoothness of a well-oiled machine. While I had to make some sacrifices along the way, the result is a more cohesive and polished user experience. After all, in the world of web development, sometimes less is more.
As we move on to the next challenge, remember: every bug is just an opportunity to level up your coding skills. Now, let's see what other surprises this portfolio has in store for us...
One of the crown jewels of v2 is the rich text and code parsing system. It's what allows you to read this very blog post without wanting to gouge your eyes out. Thanks to Sanity's flexible content structure and a custom-built parser, I can now write posts that are a joy to read and a breeze to maintain.
With the additional benfit of having code snippets as well.
The result? A blogging system that makes both the reader and the developer in me proud. It ensures every post not only reads well but also functions seamlessly, like having both creative freedom and technical control in perfect balance.
Because what's the point of having a beautiful website if it's easier to find Waldo than your portfolio online? I implemented a dynamic sitemap generation system to ensure that every nook and cranny of my site is discoverable by search engines.
This isn't just about being found; it's about making a grand entrance. Now, when Google's crawlers come knocking, my site rolls out the red carpet, complete with a detailed map of all the cool stuff they should check out.
Building v2 wasn’t about learning new tools—I was already familiar with the tech stack. The real challenge lay in designing a UI/UX that matched the vision I had for my portfolio. It was about taking what I already knew and pushing it further to create something I could truly be proud of. While v2 is a massive improvement over v1, the process wasn’t about a steep learning curve, but about refining my skills and building a portfolio that showcased how far I’ve come. Still, there were some valuable takeaways:
These challenges remind us that even with familiar tools, web development is full of surprises. It's not just about knowing your tech stack—it's about being ready to problem-solve and adapt when things don't go as planned.
While v2 is out in the wild, the adventure is far from over. Future plans include:
In conclusion, creating v2 of my portfolio has been a journey of self-discovery, technical growth, and more than a few facepalm moments. It's a living document of my evolution as a developer, a testament to the power of persistent friends, and proof that you can teach an old dog new JavaScript frameworks.
So, fellow code wranglers, what's your portfolio journey been like? Have you faced similar challenges? Let's swap stories and keep pushing the boundaries of what a developer portfolio can be. After all, in the world of web development, the only constant is change—and the occasional bout of imposter syndrome.
Now, if you'll excuse me, I have a date with my code editor. These bugs aren't going to create themselves!
'use client';
import { motion } from 'framer-motion';
import { BaseAnimationWrapperProps } from '@/lib/types';
const AnimateInView = ({
children,
threshold = 0.4,
delay = 0.4,
className = '',
tag = 'div',
initial = { opacity: 0, y: 15 },
whileInView = { opacity: 1, y: 0 },
once = true,
duration = 0.5,
...rest
}: BaseAnimationWrapperProps) => {
const MotionComponent = motion[tag];
return (
<MotionComponent
initial={initial as any}
whileInView={whileInView}
transition={{
delay: delay,
duration,
}}
viewport={{ amount: threshold, once: once }}
className={` ${className}`}
{...rest}
>
{children}
</MotionComponent>
);
};
export default AnimateInView;
'use client';
import { BaseAnimationWrapperProps } from '@/lib/types';
import { motion } from 'framer-motion';
import { useEffect, useState } from 'react';
const BoxesReveal = ({
children,
className,
threshold = 0.4,
once = true,
}: BaseAnimationWrapperProps) => {
const [boxes, setBoxes] = useState<Array<number>>([]);
const [isVisible, setIsVisible] = useState(false);
useEffect(() => {
// Create an array of 100 numbers (for 100 boxes)
setBoxes(Array.from({ length: 100 }, (_, i) => i));
// Set the image to visible after the grid is rendered
setIsVisible(true);
}, []);
return (
<div className={`relative h-full ${className}`}>
<div className='absolute inset-0 grid grid-cols-10 grid-rows-10 pointer-events-none'>
{boxes.map((box) => (
<motion.div
key={box}
initial={{ opacity: 1 }}
whileInView={{ opacity: 0 }}
transition={{ delay: Math.random() * 2, duration: 1 }}
viewport={{ amount: threshold, once }}
className='bg-background z-10'
/>
))}
</div>
<div
className={`h-full w-full ${isVisible ? 'opacity-100' : 'opacity-0'}`}
>
{children}
</div>
</div>
);
};
export default BoxesReveal;
'use client';
import React, { PropsWithChildren, useContext, useRef } from 'react';
import { AnimatePresence, motion } from 'framer-motion';
import { usePathname, useSearchParams } from 'next/navigation';
import { LayoutRouterContext } from 'next/dist/shared/lib/app-router-context.shared-runtime';
import { useIsClient } from '@/lib/context/IsClientContext';
// Client-side component to use useSearchParams() for optimal AnimatePresence keying
// Ensures transitions occur on both pathname and search param changes but requires
// to be wrapped in a Suspense component to prevent build errors
const ClientSideTransition = ({ children }: PropsWithChildren<{}>) => {
const pathname = usePathname();
const searchParams = useSearchParams();
const isClient = useIsClient();
// Create a key that includes both pathname and search params
const pageKey = `${pathname}?${searchParams.toString()}`;
// Avoid "Detected multiple renderers concurrently rendering the same context provider" error
// by only rendering the animation wrapper on the client side
if (!isClient) return <>{children}</>;
return (
<AnimatePresence mode='wait'>
<motion.main
key={pageKey}
initial={{ opacity: 0, y: 50 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -50 }}
transition={{ duration: 0.5 }}
>
<FrozenRouter>{children}</FrozenRouter>
</motion.main>
</AnimatePresence>
);
};
export default ClientSideTransition;
// This component "freezes" the router context to prevent unwanted re-renders
// during page transitions. It's necessary because Framer Motion's AnimatePresence
// can cause multiple re-renders, which can lead to routing issues in Next.js 13+ App Router.
const FrozenRouter = (props: PropsWithChildren<{}>) => {
const context = useContext(LayoutRouterContext);
const frozen = useRef(context).current;
return (
<LayoutRouterContext.Provider value={frozen}>
{props.children}
</LayoutRouterContext.Provider>
);
};
import { PropsWithChildren, Suspense } from 'react';
import ClientSideTransition from './ClientSideTransition';
const PageTransition = ({ children }: PropsWithChildren<{}>) => {
return (
<Suspense fallback={<>{children}</>}>
<ClientSideTransition>{children}</ClientSideTransition>
</Suspense>
);
};
export default PageTransition;
import React, { memo } from 'react';
import Image from 'next/image';
import {
PortableText,
PortableTextReactComponents,
PortableTextTypeComponentProps,
} from '@portabletext/react';
import { Code, RichText } from '@/lib/types';
import CodeParser from './CodeParser';
type props = {
content: RichText;
};
const RichTextParser = memo(({ content }: props) => {
let codeBlockCounter = 0;
const myPortableTextComponents: PortableTextReactComponents = {
types: {
image: ({ value }) => (
<Image
loading='lazy'
src={value.imageUrl}
alt=''
className='w-4/5 mx-auto'
/>
),
callToAction: ({ value, isInline }) =>
isInline ? (
<a
href={value.url}
className='underline underline-offset-4 text-primary'
>
{value.text}
</a>
) : (
<div className='callToAction'>{value.text}</div>
),
code: ({ value }: PortableTextTypeComponentProps<Code>) => {
const id = `code-${++codeBlockCounter}`;
return (
<CodeParser
id={id}
code={value.code}
language={value.language}
filename={value.filename}
/>
);
},
},
marks: {
em: ({ children }) => <em className=''>{children}</em>,
link: ({ children, value }) => {
const target = value.href.startsWith('http') ? '_blank' : undefined;
const rel = target === '_blank' ? 'noreferrer noopener' : undefined;
return (
<a
href={value.href}
target={target}
rel={rel}
className='underline underline-offset-4 text-primary'
>
{children}
</a>
);
},
// Add any other custom marks you want to handle
},
block: {
h1: ({ children }) => (
<h1
id={children?.toString().toLowerCase().replace(/\s+/g, '-')}
className='text-4xl px-2 mt-10 mb-8'
>
{children}
</h1>
),
h2: ({ children }) => (
<h2
id={children?.toString().toLowerCase().replace(/\s+/g, '-')}
className='text-3xl px-2 mt-8 mb-6'
>
{children}
</h2>
),
h3: ({ children }) => (
<h3
id={children?.toString().toLowerCase().replace(/\s+/g, '-')}
className='text-2xl px-2 mt-6 mb-4'
>
{children}
</h3>
),
h4: ({ children }) => (
<h4 className='text-xl px-2 mt-5 mb-3 '>{children}</h4>
),
h5: ({ children }) => (
<h5 className='text-lg px-2 mt-4 mb-2 '>{children}</h5>
),
h6: ({ children }) => (
<h6 className='text-base px-2 mt-3 mb-1 '>{children}</h6>
),
normal: ({ children }) => (
<p className='text-base mb-2 p-2 opacity-80'>{children}</p>
),
blockquote: ({ children }) => (
<blockquote className='text-base italic border-l-4 pl-2 mb-2 opacity-80'>
{children}
</blockquote>
),
},
list: {
bullet: ({ children }) => (
<ul className='list-disc pl-10 pr-2 space-y-2 opacity-80 mb-4'>
{children}
</ul>
),
number: ({ children }) => (
<ol className='list-decimal pl-10 pr-2 space-y-2'>{children}</ol>
),
// Add any other custom list types you want to handle
},
listItem: {
bullet: ({ children }) => <li className=''>{children}</li>,
// Add any other custom list item types you want to handle
},
hardBreak: () => <br />,
unknownMark: () => null,
unknownType: () => null,
unknownBlockStyle: () => null,
unknownList: () => null,
unknownListItem: () => null,
};
return (
<PortableText
value={content}
components={myPortableTextComponents}
onMissingComponent={false}
/>
);
});
RichTextParser.displayName = 'Rich Text Parser';
export default RichTextParser;
import { codeToHtml } from 'shiki';
import { extractFilename } from '@/lib/utilities';
import { Code } from '@/lib/types';
import { HiOutlineArrowTopRightOnSquare } from 'react-icons/hi2';
const CodeParser = async ({ id, language, code, filename }: Code) => {
const { name, link } = extractFilename(filename);
const html = await codeToHtml(code, {
lang: language,
theme: 'material-theme-ocean',
});
return (
<div id={id}>
<h3 className='bg-foreground flex w-full -mb-[7px] p-1 rounded-t-lg border-b border-border'>
File:
<a
href={link!}
target='_blank'
rel='noreferrer noopener'
className={`flex items-center gap-1 text-primary pl-2 ${
link ? 'hover:underline underline-offset-4' : ''
}`}
>
<span className='opacity-100'>{name}</span>
{link && <HiOutlineArrowTopRightOnSquare />}
</a>
</h3>
<div dangerouslySetInnerHTML={{ __html: html }} />
</div>
);
};
export default CodeParser;
import { NextResponse } from 'next/server';
import { BASEURL, ROUTES } from '@/lib/Constants';
import { fetchSanityData } from '@/lib/sanity/client';
import { getProjectsForSEO } from '@/lib/sanity/queries';
export const dynamic = 'force-dynamic';
export const revalidate = 0;
type SanityEntry = {
slug: string;
publishedAt: string;
};
export async function GET() {
const allProjects: SanityEntry[] = await fetchSanityData(getProjectsForSEO);
const projects = mapSanityEntriesToSitemapEntries(allProjects, '/projects');
const routes = mapRoutesToSitemapEntries(ROUTES);
const allUrls = [...routes, ...projects];
const sitemapContent = generateSitemapXml(allUrls);
return new NextResponse(sitemapContent, {
headers: {
'Content-Type': 'application/xml',
},
});
}
const createSitemapEntry = (path: string, date: string) => ({
url: `${BASEURL}${path}`,
lastModified: new Date(date).toISOString(),
});
const mapSanityEntriesToSitemapEntries = (
entries: SanityEntry[],
pathPrefix: string
) =>
entries.map(({ slug, publishedAt }) =>
createSitemapEntry(`${pathPrefix}/${slug}`, publishedAt)
);
const mapRoutesToSitemapEntries = (routes: { href: string }[]) =>
routes.map(({ href }) => createSitemapEntry(href, new Date().toISOString()));
const generateSitemapXml = (urls: { url: string; lastModified: string }[]) => {
return `<?xml version="1.0" encoding="UTF-8"?>
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
${urls
.map(
(url) => `
<url>
<loc>${url.url}</loc>
<lastmod>${url.lastModified}</lastmod>
</url>
`
)
.join('')}
</urlset>`;
};