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.
This is the story of how I rebuilt my developer portfolio from the ground up. The previous version, a simple create-react-app project from my university days, was long overdue for an overhaul. So I decided to rebuild it, not just as a gallery of projects, but as a living document of my journey as a developer.
And yes, you're currently reading the article about building v2 on v2 itself. It's a bit meta, but that's the whole point.
Looking back at my first portfolio, my main feeling is pride. I built it while most of my university mates didn't have one, and I even managed to include some custom animations. It was a huge learning experience where I got my hands dirty with React and the Intersection Observer API.
But as I grew, I realized its limitations. A simple description could never capture what a project was truly about—the challenges, the breakthroughs, and how it impacted me. It wasn't properly branded or marketed, so potential employers couldn't get a real sense of my capabilities. It was a great start, but it was time to build something that truly showed what I could do.
create-react-app was a fantastic tool for its time, but it came with baggage. My old site was struggling with issues that many developers are familiar with:
It was clear that to build the features I wanted, like a proper blog and dynamic content, I needed a more modern and performant foundation.
The real catalyst for this project came from my friend, Eman. He's the type of person who's always there when you're stuck, and he often writes deep-dive blog posts about technology, sharing them with me to help me understand complex topics. Slowly, I began to appreciate the value of that kind of documentation.
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.
This is the story of how I rebuilt my developer portfolio from the ground up. The previous version, a simple create-react-app project from my university days, was long overdue for an overhaul. So I decided to rebuild it, not just as a gallery of projects, but as a living document of my journey as a developer.
And yes, you're currently reading the article about building v2 on v2 itself. It's a bit meta, but that's the whole point.
Looking back at my first portfolio, my main feeling is pride. I built it while most of my university mates didn't have one, and I even managed to include some custom animations. It was a huge learning experience where I got my hands dirty with React and the Intersection Observer API.
But as I grew, I realized its limitations. A simple description could never capture what a project was truly about—the challenges, the breakthroughs, and how it impacted me. It wasn't properly branded or marketed, so potential employers couldn't get a real sense of my capabilities. It was a great start, but it was time to build something that truly showed what I could do.
create-react-app was a fantastic tool for its time, but it came with baggage. My old site was struggling with issues that many developers are familiar with:
It was clear that to build the features I wanted, like a proper blog and dynamic content, I needed a more modern and performant foundation.
The real catalyst for this project came from my friend, Eman. He's the type of person who's always there when you're stuck, and he often writes deep-dive blog posts about technology, sharing them with me to help me understand complex topics. Slowly, I began to appreciate the value of that kind of documentation.
The final push came when I tried to revisit one of my own old projects. I couldn't remember what half the code did, which made adding new features a nightmare. That's when it hit me: Eman was right. A portfolio shouldn't just be a list of project links. It should tell a story, both for others and for my future self. This created a new standard for my work: if a project isn't interesting enough to write about in detail, does it really belong in my portfolio?
And here's a small confession: this portfolio project itself doesn't quite meet that "blog-worthy" standard I just set. But rules are meant to be bent, especially when you're the one making them. 😌
For v2, I stuck with a stack I know well, but I aimed to use it more effectively.
I wanted a design that was clean, professional, and put the focus where it belonged: on the projects. After looking for inspiration, I found the Praha Framer template. Its slick, minimalist, and dark-mode-first aesthetic was exactly what I was looking for.
I was particularly drawn to its unconventional layout, with navigation on the sides and content in the center. By choosing a minimalist design, the projects and their detailed blog posts naturally become the main event, which was the entire goal of the redesign.
In v1, I was proud of a custom hook I wrote using the Intersection Observer API for scroll animations. It was a great learning experience, teaching me a lot about managing animations manually with browser APIs.
For v2, Framer Motion was a game-changer. It allowed me to create more complex and fluid animations with far less code.
I also implemented a box reveal effect for images, turning a standard image load into a subtle, engaging animation. It's a small touch, but it adds to the polished feel of the site.
Everything was going smoothly until I tried to implement page transitions. I wanted a seamless fade-and-slide effect between pages, but I quickly ran into a long-standing compatibility issue between Framer Motion's AnimatePresence and the Next.js App Router.
What should have been a straightforward task turned into a deep dive into GitHub issues threads. I eventually found a workaround using a "freeze fix," which prevents the outgoing page from unmounting too soon, allowing the exit animation to finish.
However, this fix introduced two new problems. First, it broke my project filtering because the animation key wasn't tracking URL search parameters. Second, Next.js started throwing errors because the useSearchParams hook requires a Suspense boundary. After tackling each issue one by one, I finally landed on a working solution.
It was a classic case of solving one bug only to create a few more, but the result was worth it: the smooth, cohesive user experience I had envisioned from the start.
One of the most important features of v2 is the blog, which required a robust system for parsing and rendering rich text and code blocks from my CMS. Using Sanity's portable text format and the @portabletext/react library, I built a custom parser to render different content types.
For code blocks, I used the shiki library to get accurate, server-side syntax highlighting with the same theme used in VS Code, making the code snippets both beautiful and easy to read.
A portfolio is useless if no one can find it. To ensure the site is easily discoverable by search engines, I implemented a dynamic sitemap that is automatically generated to include all static pages and dynamic project pages from the CMS. This tells services like Google exactly what to index and helps keep my content visible.
This rebuild wasn't about learning a brand new technology. It was an exercise in refining my skills with a familiar stack to build a polished, complete product. The main challenge was in the details of UI/UX and solving the tricky compatibility issues that inevitably pop up in any project.
A few key takeaways were:
This portfolio is a perpetual work-in-progress. Next on the list are:
Creating v2 has been a rewarding process. It's a reflection of my growth as a developer and a platform I'm proud to share. If you've gone through a similar portfolio revamp, I'd love to hear about your experience and the challenges you faced.
Now, if you'll excuse me, I have some new bugs to create.
The final push came when I tried to revisit one of my own old projects. I couldn't remember what half the code did, which made adding new features a nightmare. That's when it hit me: Eman was right. A portfolio shouldn't just be a list of project links. It should tell a story, both for others and for my future self. This created a new standard for my work: if a project isn't interesting enough to write about in detail, does it really belong in my portfolio?
And here's a small confession: this portfolio project itself doesn't quite meet that "blog-worthy" standard I just set. But rules are meant to be bent, especially when you're the one making them. 😌
For v2, I stuck with a stack I know well, but I aimed to use it more effectively.
I wanted a design that was clean, professional, and put the focus where it belonged: on the projects. After looking for inspiration, I found the Praha Framer template. Its slick, minimalist, and dark-mode-first aesthetic was exactly what I was looking for.
I was particularly drawn to its unconventional layout, with navigation on the sides and content in the center. By choosing a minimalist design, the projects and their detailed blog posts naturally become the main event, which was the entire goal of the redesign.
In v1, I was proud of a custom hook I wrote using the Intersection Observer API for scroll animations. It was a great learning experience, teaching me a lot about managing animations manually with browser APIs.
For v2, Framer Motion was a game-changer. It allowed me to create more complex and fluid animations with far less code.
I also implemented a box reveal effect for images, turning a standard image load into a subtle, engaging animation. It's a small touch, but it adds to the polished feel of the site.
Everything was going smoothly until I tried to implement page transitions. I wanted a seamless fade-and-slide effect between pages, but I quickly ran into a long-standing compatibility issue between Framer Motion's AnimatePresence and the Next.js App Router.
What should have been a straightforward task turned into a deep dive into GitHub issues threads. I eventually found a workaround using a "freeze fix," which prevents the outgoing page from unmounting too soon, allowing the exit animation to finish.
However, this fix introduced two new problems. First, it broke my project filtering because the animation key wasn't tracking URL search parameters. Second, Next.js started throwing errors because the useSearchParams hook requires a Suspense boundary. After tackling each issue one by one, I finally landed on a working solution.
It was a classic case of solving one bug only to create a few more, but the result was worth it: the smooth, cohesive user experience I had envisioned from the start.
One of the most important features of v2 is the blog, which required a robust system for parsing and rendering rich text and code blocks from my CMS. Using Sanity's portable text format and the @portabletext/react library, I built a custom parser to render different content types.
For code blocks, I used the shiki library to get accurate, server-side syntax highlighting with the same theme used in VS Code, making the code snippets both beautiful and easy to read.
A portfolio is useless if no one can find it. To ensure the site is easily discoverable by search engines, I implemented a dynamic sitemap that is automatically generated to include all static pages and dynamic project pages from the CMS. This tells services like Google exactly what to index and helps keep my content visible.
This rebuild wasn't about learning a brand new technology. It was an exercise in refining my skills with a familiar stack to build a polished, complete product. The main challenge was in the details of UI/UX and solving the tricky compatibility issues that inevitably pop up in any project.
A few key takeaways were:
This portfolio is a perpetual work-in-progress. Next on the list are:
Creating v2 has been a rewarding process. It's a reflection of my growth as a developer and a platform I'm proud to share. If you've gone through a similar portfolio revamp, I'd love to hear about your experience and the challenges you faced.
Now, if you'll excuse me, I have some new bugs to create.
'use client';
import { motion, useInView } from 'framer-motion';
import { BaseAnimationWrapperProps } from '@/lib/types';
import { useRef } from 'react';
import { FADE_IN_UP } from '@/lib/Constants';
const AnimateInView = ({
children,
threshold = 0.4,
delay = 0.4,
tag = 'div',
variants = FADE_IN_UP,
once = true,
duration = 0.5,
...rest
}: BaseAnimationWrapperProps) => {
const MotionComponent = motion[tag];
const ref = useRef(null);
const isInView = useInView(ref);
return (
<MotionComponent
ref={ref}
variants={variants}
initial='initial'
whileInView={isInView ? 'whileInView' : undefined}
transition={{
delay: delay,
duration,
}}
{...rest}
viewport={{ once: once, amount: threshold }}
>
{children}
</MotionComponent>
);
};
export default AnimateInView;
'use client';
import { motion, useInView } from 'framer-motion';
import { BaseAnimationWrapperProps } from '@/lib/types';
import { useRef } from 'react';
import { FADE_IN_UP } from '@/lib/Constants';
const AnimateInView = ({
children,
threshold = 0.4,
delay = 0.4,
tag = 'div',
variants = FADE_IN_UP,
once = true,
duration = 0.5,
...rest
}: BaseAnimationWrapperProps) => {
const MotionComponent = motion[tag];
const ref = useRef(null);
const isInView = useInView(ref);
return (
<MotionComponent
ref={ref}
variants={variants}
initial='initial'
whileInView={isInView ? 'whileInView' : undefined}
transition={{
delay: delay,
duration,
}}
{...rest}
viewport={{ once: once, amount: threshold }}
>
{children}
</MotionComponent>
);
};
export default AnimateInView;
import React, { memo } from 'react';
import Image from 'next/image';
import {
PortableText,
PortableTextReactComponents,
PortableTextTypeComponentProps,
} from '@portabletext/react';
import CodeParser from './CodeParser';
import { createSlug } from '@/lib/utilities';
import { urlFor } from '@/lib/sanity/client';
import CodeGroup from './CodeGroup';
import { BlockContent, Code, Snippet, SnippetGroup } from '@/lib/sanity/types';
import { CODE_ID_PREFIX } from '@/lib/Constants';
type props = {
content: BlockContent;
};
const RichTextParser = memo(({ content }: props) => {
let codeBlockCounter = 0;
let imageIndex = 0;
const myPortableTextComponents: PortableTextReactComponents = {
types: {
image: ({ value }) => {
const imgUrl = urlFor(value).url();
imageIndex++;
return (
<figure className='border-4 border-primary rounded-lg'>
<Image
width={1080}
height={1080}
src={imgUrl}
loading='lazy'
alt={`Image(${imageIndex})`}
/>
</figure>
);
},
callToAction: ({ value, isInline }) =>
isInline ? (
<a
href={value.url}
className='underline underline-offset-4 text-primary'
>
{value.text}
</a>
) : (
<div className='callToAction'>{value.text}</div>
),
snippet: ({ value }: PortableTextTypeComponentProps<Snippet>) => {
const id = `${CODE_ID_PREFIX}${++codeBlockCounter}`;
return <CodeParser id={id} snippet={value} />;
},
snippetGroup: ({
value,
}: PortableTextTypeComponentProps<SnippetGroup>) => {
const id = `${CODE_ID_PREFIX}${++codeBlockCounter}`;
return <CodeGroup group={value} id={id} />;
},
},
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={createSlug(children?.toString() || '')}
className='text-4xl scroll-m-16 px-2 mt-10 mb-8'
>
{children}
</h1>
),
h2: ({ children }) => (
<h2
id={createSlug(children?.toString() || '')}
className='text-3xl scroll-m-16 px-2 mt-8 mb-6'
>
{children}
</h2>
),
h3: ({ children }) => (
<h3
id={createSlug(children?.toString() || '')}
className='text-2xl scroll-m-16 px-2 mt-6 mb-4'
>
{children}
</h3>
),
h4: ({ children }) => (
<h4
id={createSlug(children?.toString() || '')}
className='text-xl scroll-m-16 px-2 mt-5 mb-3 '
>
{children}
</h4>
),
h5: ({ children }) => (
<h5
id={createSlug(children?.toString() || '')}
className='text-lg scroll-m-16 px-2 mt-4 mb-2 '
>
{children}
</h5>
),
h6: ({ children }) => (
<h6
id={createSlug(children?.toString() || '')}
className='text-base scroll-m-16 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 React, { memo } from 'react';
import Image from 'next/image';
import {
PortableText,
PortableTextReactComponents,
PortableTextTypeComponentProps,
} from '@portabletext/react';
import CodeParser from './CodeParser';
import { createSlug } from '@/lib/utilities';
import { urlFor } from '@/lib/sanity/client';
import CodeGroup from './CodeGroup';
import { BlockContent, Code, Snippet, SnippetGroup } from '@/lib/sanity/types';
import { CODE_ID_PREFIX } from '@/lib/Constants';
type props = {
content: BlockContent;
};
const RichTextParser = memo(({ content }: props) => {
let codeBlockCounter = 0;
let imageIndex = 0;
const myPortableTextComponents: PortableTextReactComponents = {
types: {
image: ({ value }) => {
const imgUrl = urlFor(value).url();
imageIndex++;
return (
<figure className='border-4 border-primary rounded-lg'>
<Image
width={1080}
height={1080}
src={imgUrl}
loading='lazy'
alt={`Image(${imageIndex})`}
/>
</figure>
);
},
callToAction: ({ value, isInline }) =>
isInline ? (
<a
href={value.url}
className='underline underline-offset-4 text-primary'
>
{value.text}
</a>
) : (
<div className='callToAction'>{value.text}</div>
),
snippet: ({ value }: PortableTextTypeComponentProps<Snippet>) => {
const id = `${CODE_ID_PREFIX}${++codeBlockCounter}`;
return <CodeParser id={id} snippet={value} />;
},
snippetGroup: ({
value,
}: PortableTextTypeComponentProps<SnippetGroup>) => {
const id = `${CODE_ID_PREFIX}${++codeBlockCounter}`;
return <CodeGroup group={value} id={id} />;
},
},
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={createSlug(children?.toString() || '')}
className='text-4xl scroll-m-16 px-2 mt-10 mb-8'
>
{children}
</h1>
),
h2: ({ children }) => (
<h2
id={createSlug(children?.toString() || '')}
className='text-3xl scroll-m-16 px-2 mt-8 mb-6'
>
{children}
</h2>
),
h3: ({ children }) => (
<h3
id={createSlug(children?.toString() || '')}
className='text-2xl scroll-m-16 px-2 mt-6 mb-4'
>
{children}
</h3>
),
h4: ({ children }) => (
<h4
id={createSlug(children?.toString() || '')}
className='text-xl scroll-m-16 px-2 mt-5 mb-3 '
>
{children}
</h4>
),
h5: ({ children }) => (
<h5
id={createSlug(children?.toString() || '')}
className='text-lg scroll-m-16 px-2 mt-4 mb-2 '
>
{children}
</h5>
),
h6: ({ children }) => (
<h6
id={createSlug(children?.toString() || '')}
className='text-base scroll-m-16 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;
'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.div
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.div>
</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;
'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.div
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.div>
</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 { 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>`;
};
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>`;
};