How to Build Category-Aware Popups in Next.js Using Sanity CMS

Personalized newsletter popups that adapt to post categories with Sanity and Next.js

·Matija Žiberna·
How to Build Category-Aware Popups in Next.js Using Sanity CMS

📋 Complete Sanity Development Guides

Get practical Sanity guides with working examples, schema templates, and time-saving prompts. Everything you need to build faster with Sanity CMS.

No spam. Unsubscribe anytime.

I was building a developer blog when I realized my generic newsletter popup wasn't converting well. Visitors reading React tutorials were seeing the same generic "subscribe for updates" message as those reading Docker guides. After implementing a category-aware popup system that automatically detects post topics and shows targeted content, conversion rates improved significantly. This guide shows you exactly how to build this dynamic popup system using Next.js and Sanity CMS.

The Challenge with Generic Popups

Most newsletter popups use one-size-fits-all messaging. A visitor reading about Sanity CMS development sees the same popup as someone learning Docker containerization. This generic approach misses the opportunity to speak directly to each reader's specific interests.

The solution is a popup system that automatically detects what category of content the user is reading and shows personalized messaging. Instead of "Subscribe to our newsletter," React developers see "Get React Mastery Updates" while Shopify developers see "Join Shopify Developers."

Setting Up Category Detection with Sanity

The foundation of our system is detecting post categories from Sanity CMS. We need a query that fetches category information for any given post slug.

// File: src/sanity/queries/post.ts
export const getPostCategoryBySlug = `
  *[_type == "post" && slug.current == $slug][0]{
    categories[]->{
      slug,
      title,
      categoryType
    }
  }
`

This query finds a post by its slug and returns all associated categories with their slug values. The slug is what we'll use to match against our target categories. Each category in Sanity should have a slug field that corresponds to technology names like "react," "nextjs," or "sanity."

The query structure allows for posts with multiple categories while giving us the specific data we need for popup personalization. We're fetching the category slug, which becomes our matching key for determining which popup configuration to show.

Creating the Category Lookup API Route

Next, we need an API route that takes a post slug and returns the relevant category for popup customization.

// File: src/app/api/post-category/route.ts
import { NextRequest, NextResponse } from 'next/server'
import { sanityFetch } from '@/sanity/lib/client'
import { getPostCategoryBySlug } from '@/sanity/queries/post'

interface CategoryResponse {
  categorySlug: string | null
  error?: string
}

interface PostWithCategories {
  categories?: {
    slug: {
      current: string
    }
    title: string
    categoryType?: string
  }[]
}

// Target category slugs we have specific popup configs for
const TARGET_CATEGORY_SLUGS = [
  'sanity', 'remix', 'shopify', 'nextjs', 'payload', 
  'cloudflare', 'react', 'docker'
]

export async function GET(request: NextRequest) {
  try {
    const { searchParams } = new URL(request.url)
    const slug = searchParams.get('slug')
    
    if (!slug) {
      return NextResponse.json({ 
        categorySlug: null, 
        error: 'Slug parameter required' 
      }, { status: 400 })
    }

    const post = await sanityFetch<PostWithCategories>({
      query: getPostCategoryBySlug,
      params: { slug },
      tags: [`post:${slug}`]
    })

    // Find the first category that matches our target slugs
    let matchedCategorySlug: string | null = null
    
    if (post?.categories) {
      for (const category of post.categories) {
        const categorySlug = category.slug?.current
        if (categorySlug && TARGET_CATEGORY_SLUGS.includes(categorySlug)) {
          matchedCategorySlug = categorySlug
          break // Use the first match
        }
      }
    }
    
    return NextResponse.json({ 
      categorySlug: matchedCategorySlug 
    }, {
      headers: {
        'Cache-Control': 'public, s-maxage=300, stale-while-revalidate=600'
      }
    })
    
  } catch (error) {
    console.error('Error fetching post category:', error)
    return NextResponse.json({ 
      categorySlug: null, 
      error: 'Failed to fetch category' 
    }, { status: 500 })
  }
}

This API route implements several important patterns. The TARGET_CATEGORY_SLUGS array defines which categories have custom popup configurations. When a post has multiple categories, we use the first matching target category, ensuring consistent behavior.

The PostWithCategories interface provides proper TypeScript typing for the Sanity response, preventing compilation errors. The generic type parameter <PostWithCategories> ensures the sanityFetch function returns properly typed data.

The caching headers improve performance by storing responses for 5 minutes with stale-while-revalidate for an additional 10 minutes. This reduces API calls while keeping content reasonably fresh. The route returns null when no target categories are found, which triggers our default popup configuration.

Building the Category Configuration System

Now we create a configuration system that maps category slugs to specific popup content and images.

// File: src/components/newsletter/category-popup-config.ts
import { StaticImageData } from 'next/image'
import portraitImage from '@/assets/matija-ziberna_portrait.jpeg'

export interface PopupConfig {
  title: string
  subtitle: string
  description: string
  ctaText: string
  placeholder: string
  image?: StaticImageData
}

export const CATEGORY_POPUP_CONFIG: Record<string, PopupConfig> = {
  sanity: {
    title: "Master Sanity CMS Development",
    subtitle: "Get exclusive Sanity tips & tutorials",
    description: "Join developers learning advanced Sanity techniques, schema design patterns, and headless CMS best practices.",
    ctaText: "Get Sanity Updates",
    placeholder: "your-email@example.com"
  },
  react: {
    title: "React Mastery Updates",
    subtitle: "Advanced React patterns",
    description: "Stay updated with React 19, concurrent features, performance patterns, and modern development practices.",
    ctaText: "Get React Updates",
    placeholder: "your-email@example.com"
  },
  nextjs: {
    title: "Next.js Expert Insights",
    subtitle: "Advanced Next.js development",
    description: "Master App Router, server components, performance optimization, and production deployment strategies.",
    ctaText: "Join Next.js Community",
    placeholder: "your-email@example.com"
  },
  default: {
    title: "Stay Updated with Latest Tech",
    subtitle: "Web development insights",
    description: "Get the latest tutorials, tips, and insights on modern web development technologies and best practices.",
    ctaText: "Subscribe to Updates",
    placeholder: "your-email@example.com",
    image: portraitImage
  }
}

// Helper to get config by category slug
export function getPopupConfigBySlug(categorySlug: string | null): PopupConfig {
  if (!categorySlug || !CATEGORY_POPUP_CONFIG[categorySlug]) {
    return CATEGORY_POPUP_CONFIG.default
  }
  return CATEGORY_POPUP_CONFIG[categorySlug]
}

This configuration system separates content from logic, making it easy to add new categories or update existing ones. Each configuration includes all the text elements needed for a personalized popup experience. The StaticImageData type ensures we can use Next.js optimized images for each category.

The export keyword on the PopupConfig interface is crucial for TypeScript compilation - without it, you'll encounter build errors when importing the interface in other files.

The helper function provides a clean interface for retrieving configurations with automatic fallback to the default when no specific configuration exists. This prevents errors and ensures every visitor sees an appropriate popup.

Creating the Category Detection Hook

We need a React hook that manages category detection with caching to avoid repeated API calls.

// File: src/hooks/use-post-category.ts
'use client'

import { useState, useEffect } from 'react'

interface CategoryResponse {
  categorySlug: string | null
  error?: string
}

// In-memory cache to avoid repeated requests
const categoryCache = new Map<string, string | null>()

export function usePostCategory(slug: string | null) {
  const [categorySlug, setCategorySlug] = useState<string | null>(null)
  const [loading, setLoading] = useState(false)
  const [error, setError] = useState<string | null>(null)

  useEffect(() => {
    if (!slug) {
      setCategorySlug(null)
      return
    }

    // Check cache first
    if (categoryCache.has(slug)) {
      setCategorySlug(categoryCache.get(slug) || null)
      return
    }

    setLoading(true)
    setError(null)

    fetch(`/api/post-category?slug=${encodeURIComponent(slug)}`)
      .then(res => res.json())
      .then((data: CategoryResponse) => {
        const categorySlug = data.categorySlug
        setCategorySlug(categorySlug)
        categoryCache.set(slug, categorySlug) // Cache the result
        
        if (data.error) {
          setError(data.error)
        }
      })
      .catch(err => {
        console.error('Failed to fetch post category:', err)
        setError('Failed to fetch category')
        setCategorySlug(null)
        categoryCache.set(slug, null) // Cache the failure
      })
      .finally(() => {
        setLoading(false)
      })

  }, [slug])

  return { categorySlug, loading, error }
}

This hook implements client-side caching to improve performance. Once a category is fetched for a slug, subsequent requests use the cached value. The hook handles loading states and errors gracefully, ensuring the popup system remains functional even when API calls fail.

The in-memory cache persists for the session, so users navigating between posts don't trigger unnecessary API calls. This is particularly important for good user experience on slower connections.

Building URL Detection Utilities

We need utilities to extract post slugs from URLs and determine when we're on blog posts.

// File: src/lib/slug-utils.ts
export function extractSlugFromPath(pathname: string): string | null {
  // Match /blog/[slug] pattern
  const blogMatch = pathname.match(/^\/blog\/([^\/]+)$/)
  return blogMatch ? blogMatch[1] : null
}

export function isBlogPost(pathname: string): boolean {
  return /^\/blog\/[^\/]+$/.test(pathname)
}

These utilities handle URL parsing reliably. The extractSlugFromPath function uses regex to extract the slug portion from blog URLs, while isBlogPost determines whether the current page should show a popup. This separation keeps the logic clean and testable.

The regex patterns are specific to the /blog/[slug] URL structure but can be adapted for different routing patterns in your application.

Updating the Popup Component for Dynamic Content

Now we modify the existing popup component to accept and use dynamic configurations.

// File: src/components/newsletter/newsletter-popup.tsx (key changes)
import { PopupConfig, CATEGORY_POPUP_CONFIG } from './category-popup-config'

interface NewsletterPopupProps {
  onClose: () => void
  config?: PopupConfig
}

export function NewsletterPopup({ onClose, config }: NewsletterPopupProps) {
  // Use provided config or fall back to default
  const popupConfig = config || CATEGORY_POPUP_CONFIG.default
  
  // ... existing component logic

  return (
    <div className="fixed inset-0 bg-black/50 backdrop-blur-sm z-50">
      <div className="bg-white dark:bg-gray-900 max-w-4xl mx-auto">
        <div className="flex flex-col md:flex-row">
          {/* Dynamic image */}
          <div className="relative h-48 md:h-auto md:w-1/2">
            <Image
              src={popupConfig.image || portraitImage}
              alt="Newsletter signup"
              fill
              className="object-cover"
            />
          </div>
          
          <div className="p-8 md:w-1/2">
            {/* Dynamic title and subtitle */}
            <h1 className="text-3xl font-bold mb-4">
              {popupConfig.title}
            </h1>
            <p className="text-gray-600 mb-6">
              {popupConfig.subtitle}
            </p>
            
            {/* Dynamic description */}
            <p className="mb-6">
              {popupConfig.description}
            </p>
            
            <form>
              <input
                type="email"
                placeholder={popupConfig.placeholder}
                className="w-full p-3 border rounded mb-4"
              />
              <button className="w-full bg-blue-600 text-white p-3 rounded">
                {popupConfig.ctaText}
              </button>
            </form>
          </div>
        </div>
      </div>
    </div>
  )
}

The component now accepts an optional config prop and falls back to the default configuration when none is provided. This maintains backward compatibility while enabling category-specific customization. The dynamic image support uses Next.js Image optimization automatically.

All text elements now pull from the configuration object, making the popup completely customizable based on the detected category. The fallback pattern ensures the popup always renders correctly even when category detection fails.

Implementing the Category-Aware Controller

Finally, we create the controller that coordinates category detection with popup display.

// File: src/components/newsletter/newsletter-popup-controller.tsx
'use client'

import { useState, useEffect } from 'react'
import { usePathname } from 'next/navigation'
import { useScrollDetection } from '@/hooks/use-scroll-detection'
import { usePostCategory } from '@/hooks/use-post-category'
import { hasNewsletterPopupBeenShown, setNewsletterPopupShown } from '@/lib/newsletter-cookie-utils'
import { NewsletterPopup } from './newsletter-popup'
import { getPopupConfigBySlug } from './category-popup-config'
import { extractSlugFromPath, isBlogPost } from '@/lib/slug-utils'

export function NewsletterPopupController() {
  const [showPopup, setShowPopup] = useState(false)
  const [delayComplete, setDelayComplete] = useState(false)
  const hasScrolledPastThreshold = useScrollDetection(0.25)
  
  const pathname = usePathname()
  const slug = extractSlugFromPath(pathname)
  const { categorySlug, loading } = usePostCategory(slug)

  useEffect(() => {
    if (typeof window === 'undefined') return
    
    const hasBeenShown = hasNewsletterPopupBeenShown()
    if (hasBeenShown) return

    // Only show popup on blog posts
    if (!isBlogPost(pathname)) return

    // Wait for category to load (don't show popup while loading)
    if (slug && loading) return

    // Start delay timer when scroll threshold is reached
    if (hasScrolledPastThreshold && !delayComplete) {
      const timeoutId = setTimeout(() => {
        setDelayComplete(true)
      }, 200)
      return () => clearTimeout(timeoutId)
    }

    // Show popup after delay is complete
    if (delayComplete && !hasBeenShown) {
      setShowPopup(true)
    }
  }, [hasScrolledPastThreshold, delayComplete, pathname, slug, loading])

  const handleClose = () => {
    setShowPopup(false)
    setNewsletterPopupShown()
  }

  if (!showPopup) {
    return null
  }

  // Get category-specific config by slug
  const config = getPopupConfigBySlug(categorySlug)

  return <NewsletterPopup onClose={handleClose} config={config} />
}

This controller orchestrates the entire system. It waits for category detection to complete before showing the popup, ensuring users always see the appropriate content. The loading state prevents the popup from showing prematurely with default content when category-specific content should be displayed.

The controller maintains all existing popup behavior like scroll detection and cookie-based display limiting while adding the new category awareness. This preserves user experience while enabling the personalization features.

The Complete User Experience

With this system in place, the user experience becomes significantly more targeted. A developer reading a React tutorial sees a popup titled "React Mastery Updates" with React-specific messaging. Someone reading about Sanity CMS sees "Master Sanity CMS Development" with CMS-focused content.

The system handles edge cases gracefully. Posts without matching categories show the default popup. Network errors fall back to default content. Multiple categories use the first matching target category. This robust fallback system ensures every visitor sees an appropriate popup.

Performance remains excellent through caching at multiple levels. API responses are cached server-side. Category lookups are cached client-side. Images are optimized automatically by Next.js. The result is a sophisticated personalization system that doesn't compromise on speed.

Common TypeScript Issues and Solutions

During implementation, you might encounter these TypeScript compilation errors:

Error: "Property 'categories' does not exist on type '{}'"

  • Solution: Add the PostWithCategories interface and use it as a generic type parameter: sanityFetch<PostWithCategories>

Error: "Module declares 'PopupConfig' locally, but it is not exported"

  • Solution: Export the interface: export interface PopupConfig

Error: "Cannot find module './category-popup-config'"

  • Solution: Ensure all imports match your actual file structure and that TypeScript files have proper extensions

These errors typically occur when TypeScript can't infer types from external data sources like Sanity CMS. Proper interface definitions resolve these issues and provide better development experience with autocompletion and type checking.

Beyond Basic Personalization

This category-aware popup system opens up possibilities beyond simple content customization. You could implement different signup flows for different categories, track conversion rates by topic, or even adjust popup timing based on content type.

The foundation you've built is extensible. Adding new categories requires only updating the configuration object. The API route automatically handles new category slugs. The popup component adapts without code changes.

You now have a complete system that automatically detects post categories from Sanity CMS and shows personalized newsletter popups. The implementation combines Next.js App Router patterns with Sanity integration to create a seamless, category-aware user experience that speaks directly to each visitor's interests.

Let me know in the comments if you have questions, and subscribe for more practical development guides.

Thanks, Matija

0

Comments

Leave a Comment

Your email will not be published

10-2000 characters

• Comments are automatically approved and will appear immediately

• Your name and email will be saved for future comments

• Be respectful and constructive in your feedback

• No spam, self-promotion, or off-topic content

Matija Žiberna
Matija Žiberna
Full-stack developer, co-founder

I'm Matija Žiberna, a self-taught full-stack developer and co-founder passionate about building products, writing clean code, and figuring out how to turn ideas into businesses. I write about web development with Next.js, lessons from entrepreneurship, and the journey of learning by doing. My goal is to provide value through code—whether it's through tools, content, or real-world software.

You might be interested in