DEV Community

Cover image for Building an Intelligent Portfolio Filtering System with Next.js and React Context
Ryan VerWey
Ryan VerWey

Posted on • Originally published at ryanverwey.dev

Building an Intelligent Portfolio Filtering System with Next.js and React Context

The Challenge: Helping Recruiters Find What Matters

When building a portfolio website, one of the biggest challenges is information overload. You want to showcase everything you've built - your full-stack capabilities, data projects, UI/UX work, project management experience - but recruiters and hiring managers often have specific interests.

A front-end recruiter doesn't need to sift through your database optimization projects, and a data science manager shouldn't have to scroll past your Angular components.

The solution? An intelligent filtering system that asks visitors what they're interested in and quietly highlights the most relevant content throughout the entire portfolio.

Design Goals and UX Principles

Before writing a single line of code, I established clear design goals based on fundamental UX principles:

1. Progressive Disclosure

Don't overwhelm users with all filtering options at once. Start with high-level interests (Full Stack, Web Development, Data, etc.) and let the system handle the complexity behind the scenes.

2. Persistence Without Friction

Filter selections should persist across page navigation and browser sessions, but users should be able to adjust them at any time without going through a complex flow.

3. Subtle Over Aggressive

Visual highlighting should guide attention without screaming. Instead of hiding non-matching content (which feels restrictive), use subtle visual cues - lighter borders, reduced opacity - to naturally draw the eye to relevant items.

4. F-Pattern Optimization

Leverage the natural F-shaped reading pattern by placing filter controls at the top of each page and using horizontal layouts that align with how users scan content.

5. First-Time User Experience

Show a welcome modal only once per session, making it feel like a helpful concierge rather than an annoying popup.

6. URL-Based Context Setting

Enable direct linking to filtered views through URL parameters, allowing users to share specific portfolio contexts or bookmark their preferred view.

Technical Architecture

Technology Stack

  • Next.js 14 with App Router for server-side rendering and optimal performance
  • React Context API for global state management
  • TypeScript for type safety across the entire filtering system
  • localStorage and sessionStorage for different persistence needs
  • Tailwind CSS for responsive, utility-first styling

Core Components

The system is built around four key architectural pieces:

1. Central Filter Configuration (lib/filters.ts)

The heart of the system is a centralized configuration file that maps high-level interest categories to specific technology tags:

export const filterPresets: Record<InterestCategory, FilterPreset> = {
  'full-stack': {
    id: 'full-stack',
    label: 'Full Stack Development',
    description: 'End-to-end application development with modern frameworks',
    tags: [
      'React', 'Angular', 'Vue', 'Next.js',
      'Node.js', 'Express', '.NET', 'C#',
      'JavaScript', 'TypeScript', 'Python',
      'SQL', 'SQL Server', 'MongoDB',
      'REST APIs', 'CI/CD', 'Docker', 'Kubernetes'
    ]
  },
  // ... more presets
}
Enter fullscreen mode Exit fullscreen mode

This approach follows the Single Source of Truth principle - all filtering logic stems from this one configuration, making it easy to add new categories or adjust mappings.

The file also includes helper functions for matching logic:

export function matchesFilters(itemTags: string[], activeFilters: string[]): boolean {
  return activeFilters.some(filter => itemTags.includes(filter))
}

export function getFilterStrength(itemTags: string[], activeFilters: string[]): number {
  if (activeFilters.length === 0) return 1
  const matches = itemTags.filter(tag => activeFilters.includes(tag)).length
  return matches / activeFilters.length
}
Enter fullscreen mode Exit fullscreen mode

The getFilterStrength function is crucial - it returns a 0-1 score indicating how strongly an item matches active filters, enabling nuanced visual highlighting.

2. Global State Management (contexts/FilterContext.tsx)

React Context provides global state without prop drilling. The FilterContext manages:

  • activeFilters: Array of currently active technology tags
  • selectedInterest: The high-level category chosen by the user
  • hasSeenWelcome: Whether the user has dismissed the welcome modal

The implementation uses lazy initialization to avoid hydration mismatches, a common gotcha in Next.js:

const [activeFilters, setActiveFilters] = useState<string[]>(() => {
  if (typeof window !== 'undefined') {
    const stored = localStorage.getItem('portfolioFilters')
    return stored ? JSON.parse(stored) : []
  }
  return []
})
Enter fullscreen mode Exit fullscreen mode

This pattern ensures localStorage is only accessed on the client, preventing server/client render discrepancies.

URL Parameter Integration

A powerful feature of the filtering system is the ability to set filters directly through URL parameters. This enables shareable links and bookmarkable views:

const loadInitialInterest = (): InterestCategory | null => {
  if (typeof window === 'undefined') return null

  // Check URL parameter first
  const urlParams = new URLSearchParams(window.location.search)
  const urlInterest = urlParams.get('interest')

  if (urlInterest && urlInterest in filterPresets) {
    return urlInterest as InterestCategory
  }

  // Fall back to stored preference
  const stored = localStorage.getItem('portfolioInterest')
  return stored ? (stored as InterestCategory) : null
}
Enter fullscreen mode Exit fullscreen mode

This creates several powerful use cases:

  • Direct Links: Share https://www.ryanverwey.dev/?interest=full-stack to showcase full-stack work
  • Resume Integration: Add filtered links to your resume for role-specific portfolios
  • Job Applications: Send ?interest=web-development to a front-end position
  • Social Media: Tweet different filtered views for different audiences

The URL parameter takes precedence over stored preferences, ensuring shared links always display the intended context. After initial load, the selection is persisted to localStorage for subsequent navigation.

The context also provides methods for manipulating filters:

const applyPreset = useCallback((interest: InterestCategory) => {
  const preset = filterPresets[interest]
  setSelectedInterest(interest)
  setActiveFilters(interest === 'browse-all' ? [] : preset.tags)
}, [])
Enter fullscreen mode Exit fullscreen mode

3. Welcome Modal (components/WelcomeModal.tsx)

First impressions matter. The welcome modal appears once per session, presenting six interest options in a clean, scannable grid:

<div className="fixed inset-0 z-50 flex items-center justify-center p-4 
     bg-black/50 backdrop-blur-sm animate-in fade-in duration-200">
  <div className="relative w-full max-w-lg bg-white dark:bg-zinc-900 
       rounded-xl shadow-xl">
    {/* Interest options in a 2-column grid */}
  </div>
</div>
Enter fullscreen mode Exit fullscreen mode

Key UX decisions:

  • Backdrop blur creates depth and focuses attention
  • Max-width constraint ensures readability on large screens
  • Skip option respects user autonomy, no forced interaction
  • sessionStorage (not localStorage) means the modal returns in new sessions, gently reminding return visitors they can customize their view

4. Filter Bar (components/FilterBar.tsx)

The FilterBar appears consistently at the top of filtered pages, providing both context and control:

<div className="bg-white/80 dark:bg-zinc-950/80 backdrop-blur-sm 
     border-b border-zinc-200 dark:border-zinc-800 mb-8">
  <div className="container mx-auto max-w-6xl px-4 py-4">
    {/* Interest selector buttons */}
    {/* Active filters display */}
  </div>
</div>
Enter fullscreen mode Exit fullscreen mode

The semi-transparent background with backdrop blur creates a modern, elevated feel while maintaining visual hierarchy.

Implementation Patterns

Highlighting Strategy

The highlighting system uses a three-tier approach based on match strength:

const getHighlightClass = (tags: string[]) => {
  if (activeFilters.length === 0) return ''
  const strength = getFilterStrength(tags, activeFilters)

  if (strength >= 0.5) {
    return 'ring-1 ring-zinc-400 dark:ring-zinc-600'
  } else if (strength > 0) {
    return 'ring-1 ring-zinc-300 dark:ring-zinc-700'
  } else {
    return 'opacity-40'
  }
}
Enter fullscreen mode Exit fullscreen mode

This creates a visual hierarchy:

  • Strong matches (50%+ tags match) get a noticeable but subtle ring
  • Partial matches get a lighter ring
  • Non-matches are dimmed but still visible

This follows the Recognition Over Recall principle - users can see all content but immediately recognize what's most relevant.

Type Safety Throughout

TypeScript ensures compile-time safety across the entire system:

export type InterestCategory = 
  | 'full-stack'
  | 'web-development'
  | 'back-end'
  | 'data'
  | 'project-management'
  | 'browse-all'

interface FilterPreset {
  id: InterestCategory
  label: string
  description: string
  tags: string[]
}
Enter fullscreen mode Exit fullscreen mode

Union types for categories prevent typos and enable autocomplete. Every filter interaction is type-checked, reducing runtime errors.

Performance Considerations

Several optimizations ensure the filtering system doesn't impact performance:

  1. Memoization with useCallback: Filter operations are memoized to prevent unnecessary re-renders
  2. Lazy initialization: State is initialized once, not on every render
  3. Selective filtering: Projects page doesn't apply filtering logic (only highlighting) to avoid jarring layout shifts
  4. Static generation: Pages remain statically generated, filtering happens entirely client-side

Accessibility

The system incorporates several accessibility features:

  • Semantic HTML: Buttons use proper <button> elements, not divs
  • Focus management: Keyboard navigation works naturally through filter options
  • Color contrast: All text meets WCAG AA standards
  • Reduced motion: Animations respect prefers-reduced-motion
  • Screen reader labels: Filter buttons clearly announce their state

Page-Specific Implementations

About Page

The About page uses highlighting on both experience cards and skill buttons:

const renderSkillButton = (skill: string) => {
  const isFiltered = activeFilters.includes(skill)
  return (
    <button
      className={`block w-full px-3 py-2 text-sm rounded-lg border transition-all ${
        isFiltered
          ? 'border-zinc-400 bg-zinc-100 dark:bg-zinc-800 font-medium'
          : 'border-zinc-200 dark:border-zinc-700 hover:border-zinc-300'
      }`}
    >
      {skill}
    </button>
  )
}
Enter fullscreen mode Exit fullscreen mode

This creates a cohesive experience where skills relevant to the selected interest are subtly emphasized.

Projects Page

Projects showcase a unique challenge - they have both category filters (Web Apps, Websites, Tools) and global filters. The solution:

const filteredProjects = projects.filter(p => {
  const categoryMatch = selectedCategory === 'All' || p.category === selectedCategory
  // Don't filter by activeFilters - only highlight
  return categoryMatch
})
Enter fullscreen mode Exit fullscreen mode

Projects remain visible regardless of global filters (avoiding confusing disappearing content), but the highlighting guides attention to relevant tech stacks.

Experience Page

The Experience page combines a collapsible skill sidebar with the global FilterBar:

const toggleSkill = (skill: string) => {
  if (activeFilters.includes(skill)) {
    removeFilter(skill)
  } else {
    addFilter(skill)
  }
}
Enter fullscreen mode Exit fullscreen mode

This allows granular control - users can click individual skills to add them to filters, creating a dynamic, exploratory experience.

Blog Page

The blog integrates with existing category/tag filters while syncing with global state:

useEffect(() => {
  if (currentTag && !activeFilters.includes(currentTag)) {
    addFilter(currentTag)
  }
}, [currentTag, activeFilters, addFilter])
Enter fullscreen mode Exit fullscreen mode

This creates continuity - selecting a tag in the blog updates global filters, so navigating to the Experience page automatically highlights related skills.

UI/UX Principles in Action

Visual Hierarchy

The entire system leverages Gestalt principles of perception:

  • Proximity: Related controls are grouped together
  • Similarity: Matching tags share visual styling
  • Continuity: The FilterBar maintains consistent positioning across pages
  • Figure-Ground: Highlighted items naturally pop against dimmed content

Cognitive Load Reduction

Rather than forcing users to understand tag taxonomies, the system presents intent-based presets:

  • "I'm interested in Full Stack Development" → System applies 20+ relevant tags
  • User never needs to know the underlying complexity
  • Follows Don Norman's principle of mapping mental models to system models

Feedback and Affordances

Every interaction provides clear feedback:

  • Hover states signal clickability
  • Active states show current selection
  • Transition animations create continuity (but are subtle, 150ms or less)
  • Read-only badges (active filters) provide context without inviting accidental interaction

Mobile Responsiveness

The system adapts to smaller screens:

.flex-col sm:flex-row sm:items-center
Enter fullscreen mode Exit fullscreen mode

On mobile, filter buttons stack vertically. On desktop, they flow horizontally. This maintains usability across all viewport sizes.

Lessons Learned

What Worked Well

  1. Central configuration: Having one source of truth made the system easy to extend and debug
  2. Subtle highlighting: Users report the filtering feels "helpful, not pushy"
  3. Browse All as default: Starting unfiltered respects user agency and prevents confusion
  4. Type safety: TypeScript caught numerous bugs during development

What I'd Do Differently

  1. Analytics integration: Tracking which interests users select would inform content strategy
  2. Fuzzy matching: Currently, matching is exact - "React" vs "ReactJS" won't match. A fuzzy matcher would be more forgiving
  3. A/B testing: Test different highlight intensities to optimize for conversion
  4. Filter combinations: Allow multiple interest selections simultaneously for hybrid roles

Performance Metrics

After deployment:

  • First Contentful Paint: < 1.2s
  • Largest Contentful Paint: < 2.0s
  • Cumulative Layout Shift: 0.01 (excellent)
  • Time to Interactive: < 2.5s

The filtering system adds negligible overhead - most logic is simple array operations, and React's reconciliation handles updates efficiently.

Conclusion

Building an intelligent filtering system isn't just about functionality - it's about understanding user intent and creating an interface that feels anticipatory rather than reactive. By combining thoughtful UX principles, modern React patterns, and performant architecture, we created a system that helps visitors find exactly what they're looking for without feeling restrictive.

The key insights:

  • Start with user research: Understand why people visit your portfolio
  • Map intents to implementations: High-level categories → specific tags
  • Prioritize subtlety: Guide, don't force
  • Maintain consistency: Same patterns across all pages
  • Enable shareability: URL parameters make filtered views shareable
  • Test on real users: Assumptions ≠ reality

This system demonstrates that sophisticated features don't require complex UIs. By leveraging fundamental design principles and modern web technologies, we created an experience that feels effortless - the hallmark of great design.

Real-World Application

The URL-based filtering has proven particularly valuable in job applications. Instead of sending recruiters to a generic portfolio, I can now send role-specific links:

  • Full-Stack Position: ?interest=full-stack - Highlights end-to-end development work
  • Front-End Role: ?interest=web-development - Emphasizes UI components and React expertise
  • Backend Position: ?interest=back-end - Showcases API development and database work
  • Data Role: ?interest=data - Features analytics, ETL, and visualization projects

This targeted approach increases engagement by showing recruiters exactly what they're looking for, without making them hunt through unrelated content.


Technologies Used: React, Next.js 14, TypeScript, Tailwind CSS, React Context API

Methodologies: User-Centered Design, Progressive Enhancement, Mobile-First Development, Accessibility-First

UI/UX Principles: Progressive Disclosure, Recognition Over Recall, F-Pattern Layout, Gestalt Principles, Visual Hierarchy

Top comments (0)