Building a Modern Portfolio Site with Next.js and Sanity

AI WrittenNext.jsSanityTypeScriptPortfolioWeb Development

How I built a modern portfolio website using Next.js 15, Sanity CMS, and Tailwind CSS, with lessons learned along the way.

Building a Modern Portfolio Site with Next.js and Sanity

šŸ“ AI Documentation Note: This article was generated by AI to document the architecture and implementation of a portfolio website project. It serves as a comprehensive guide to the technical decisions and patterns used.

I recently rebuilt my portfolio website from scratch, and I wanted to share the process and some of the decisions I made along the way. The goal was to create something that not only looks good but also provides a great content management experience and performs well.

The Stack

Here's what I ended up using:

Next.js 15 felt like the obvious choice. The App Router has matured nicely, and the built-in image optimization and SEO features were exactly what I needed. Plus, I've been building with Next.js for a while now, so I knew what to expect.

Sanity CMS was the interesting choice here. I wanted a headless CMS that wouldn't get in my way, and Sanity's approach to content modeling really clicked with me. The fact that I could embed the studio directly at /studio meant I wouldn't need to jump between different admin panels.

For styling, I stuck with Tailwind CSS. I know some people find utility classes verbose, but for a project like this where you're constantly tweaking designs, it's hard to beat the development speed.

I also added Framer Motion for animations. Nothing crazy, just some scroll-triggered effects to make the experience feel a bit more polished.

Project Structure

I organized the project like this:

web/
ā”œā”€ā”€ app/                    # Next.js App Router pages
ā”œā”€ā”€ components/             # Reusable React components
ā”œā”€ā”€ sanity/                # CMS configuration & schemas
ā”œā”€ā”€ scripts/               # Migration scripts
└── types/                 # TypeScript definitions

The key here was keeping things modular. Each component has its own TypeScript interface, which made refactoring much easier down the line.

// Example: Hero component interface
interface HeroProps {
  greeting?: string;
  name?: string;
  title: string;
  subtitle?: string;
  profileImage?: SanityImage;
  linkedinUrl?: string;
}

Content Management Strategy

One thing I learned early on: spend time on your content schema. It's tempting to just start building components, but if your content model isn't right, you'll be fighting it the entire time.

I took a schema-first approach with validation rules:

// Hero section schema
defineField({
  name: 'title',
  title: 'Main Title',
  type: 'string',
  description: 'Large, prominent text - the main headline',
  validation: (Rule) => Rule.required(),
})

All data fetching is centralized in sanity/lib/fetch.ts. This made it easy to update queries without hunting through components:

export async function getHero() {
  return await client.fetch(heroQuery)
}

export async function getCaseStudyBySlug(slug: string) {
  return await client.fetch(caseStudyBySlugQuery, { slug })
}

Case Studies: The Heart of the Site

The case study section was the most complex part. I wanted to support comprehensive project documentation with sections like Overview, Design Process, Research, Solution, and Outcomes.

The tricky part was making the navigation dynamic based on what content actually exists:

// Dynamic navigation based on available content
const navSections = [
  hasOverviewContent() && { id: 'overview', label: 'Overview' },
  (metrics?.length ?? 0) > 0 && { id: 'kpi', label: 'KPI' },
  hasDesignProcessContent() && { id: 'design-process', label: 'Design Process' },
  // ... more sections
].filter(Boolean) as { id: string; label: string }[];

This way, empty sections wouldn't show up in the navigation, keeping things clean.

Navigation Challenges

Getting the navigation right took a few iterations. On the homepage, clicking "Contact" should scroll to the contact section. But on other pages, it should navigate back to the homepage and then scroll to that section.

// Smart navigation logic
{isHomePage ? (
  <button onClick={() => scrollToSection('contact')}>
    Contact
  </button>
) : (
  <Link href="/#contact">Contact</Link>
)}

Small detail, but it makes a big difference in user experience.

Animations and Performance

I used Framer Motion for scroll-triggered animations, but kept them subtle:

<motion.div
  initial={{ opacity: 0, y: 20 }}
  whileInView={{ opacity: 1, y: 0 }}
  transition={{ duration: 0.6 }}
  viewport={{ once: true }}
>

The viewport={{ once: true }} is important here. It means the animation only plays once when the element comes into view, which helps with performance.

Image Optimization

Images were handled through Sanity's CDN combined with Next.js Image component:

const heroImageUrl = heroImage
  ? urlFor(heroImage).width(1200).height(600).url()
  : 'https://picsum.photos/1200/600?random=20';

This gives you automatic optimization, lazy loading, and responsive sizing without having to think about it.

Static Generation

All case study pages are statically generated at build time using generateStaticParams(). This means the site is fast, and I'm not hitting the Sanity API on every request:

export async function generateStaticParams() {
  const posts = await fetchAllPosts();

  return posts.map(post => ({
    slug: post.slug,
  }));
}

Design System

I went with a dark theme using pure black backgrounds with glassmorphism effects. The color palette is pretty simple:

  • Primary: Blue (#4A9FFF) for CTAs and accents
  • Background: Pure black (#000000)
  • Text: White with varying opacity (60%, 70%, 80%)
  • Borders: White with low opacity (10-20%)

The backdrop blur effects give it a modern feel:

/* Glassmorphism */
.bg-black/80.backdrop-blur-md

Real-Time Updates with Webhooks

One of the coolest features is the webhook integration with Vercel. When content changes in Sanity, it automatically triggers a revalidation:

export async function POST(request: NextRequest) {
  const { searchParams } = new URL(request.url);
  const secret = searchParams.get('secret');

  if (secret !== process.env.REVALIDATE_SECRET) {
    return NextResponse.json({ message: 'Invalid secret' }, { status: 401 });
  }

  revalidatePath('/');
  return NextResponse.json({ revalidated: true });
}

This means I can update content in Sanity and see it live on the site within seconds.

TypeScript All the Way

I made sure everything was properly typed from the start. Sanity can generate TypeScript types from your schemas, which is incredibly helpful:

interface CaseStudy {
  title: string;
  description: string;
  overview?: CaseStudyOverview;
  designProcess?: DesignProcess;
  // ... comprehensive typing
}

The upfront investment in types saved me countless hours of debugging later on.

Lessons Learned

Schema Evolution is Hard

One thing I didn't anticipate was how tricky schema evolution would be. I ended up writing migration scripts to handle changes, and implementing helper functions to check for content existence:

// Backward compatibility helper
const hasOverviewContent = () => {
  if (!overview) return false;
  if (Array.isArray(overview)) return overview.length > 0;
  return Boolean(overview.businessContext?.length);
};

This made it possible to update schemas without breaking existing content.

Optimize Images Aggressively

I learned to be aggressive with image optimization. The difference between a 2MB image and a properly optimized 200KB image is huge for mobile users. Sanity's image pipeline made this easy, but you have to remember to use it.

Documentation Matters

I created multiple documentation files (CLAUDE.md, PROJECT_SUMMARY.md, WEBHOOK_SETUP.md) as I built the site. Future me (and any potential contributors) will thank me for this. It's easy to forget why you made certain decisions six months later.

What's Next?

There are a few things I'd like to add:

  • Dark/Light theme toggle - Currently it's dark-only
  • Blog integration - I want to write more technical content
  • Advanced search - Make it easier to find case studies
  • Performance monitoring - Track Core Web Vitals over time

But for now, I'm happy with where it landed. The site is fast, looks good, and I can update content without touching code.

Key Takeaways

If you're building something similar, here are my recommendations:

  1. Invest in TypeScript interfaces early - They'll save you time debugging later
  2. Think about your content schema - It's harder to change later than you think
  3. Use static generation - The performance benefits are worth it
  4. Document as you go - You'll forget why you made decisions
  5. Test on real devices - The browser dev tools only tell you so much

Building a portfolio site is a great learning experience. You get to make all the technical decisions, and you're the only stakeholder. It's a chance to try new tools and patterns without the pressure of client deadlines.

If you're thinking about rebuilding your portfolio, I'd say go for it. Just don't fall into the trap of endlessly tweaking. Ship it, then iterate based on real feedback.


The site is built with Next.js 15, TypeScript, Sanity CMS, Tailwind CSS, and Framer Motion. Deployed on Vercel with automatic updates via webhooks.

Continue Reading

Browse All Articles