
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
}
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
}
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 []
})
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
}
This creates several powerful use cases:
- Direct Links: Share
https://www.ryanverwey.dev/?interest=full-stackto showcase full-stack work - Resume Integration: Add filtered links to your resume for role-specific portfolios
- Job Applications: Send
?interest=web-developmentto 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)
}, [])
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>
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>
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'
}
}
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[]
}
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:
- Memoization with useCallback: Filter operations are memoized to prevent unnecessary re-renders
- Lazy initialization: State is initialized once, not on every render
- Selective filtering: Projects page doesn't apply filtering logic (only highlighting) to avoid jarring layout shifts
- 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>
)
}
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
})
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)
}
}
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])
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
On mobile, filter buttons stack vertically. On desktop, they flow horizontally. This maintains usability across all viewport sizes.
Lessons Learned
What Worked Well
- Central configuration: Having one source of truth made the system easy to extend and debug
- Subtle highlighting: Users report the filtering feels "helpful, not pushy"
- Browse All as default: Starting unfiltered respects user agency and prevents confusion
- Type safety: TypeScript caught numerous bugs during development
What I'd Do Differently
- Analytics integration: Tracking which interests users select would inform content strategy
- Fuzzy matching: Currently, matching is exact - "React" vs "ReactJS" won't match. A fuzzy matcher would be more forgiving
- A/B testing: Test different highlight intensities to optimize for conversion
- 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
Share this article
Recommended Reading

Process Mapper: Building an Interactive Workflow Visualization Tool
Deep dive into Process Mapper, a drag-and-drop workflow visualization tool. Learn about the technical architecture, design decisions, and key features that make complex process mapping simple and intuitive.

Typing Force: Building a Real-Time Typing Speed Test with React
A comprehensive look at building Typing Force, a typing speed test application with real-time WPM calculation, accuracy tracking, and performance analytics. Learn about the algorithms, React state management, and deployment strategies behind this interactive web app.

Cairo Photography Portfolio: Building a Custom CMS for Creative Professionals
How I built a full-featured photography portfolio website with a custom content management system. Learn about the architecture, gallery optimization, and photographer-friendly admin interface that powers this modern portfolio site.