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

📋 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.
Related Posts:
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