Most newsletter signup forms are terrible. Popups that block content, aggressive tactics that annoy visitors, or buried forms nobody sees.
I wanted something different: a system that respects the reader's experience while actually converting subscribers.
With Claude Code as my partner, I built it in 6 hours. Here's the technical story.
The Problem
What I had:
- Website with good traffic but low email capture
- No clear value proposition for subscribing
- No systematic approach to asking for signups
What I wanted:
- Progressive engagement (light touch → stronger ask)
- Non-intrusive timing (after value delivery)
- Integration with ConvertKit
- A/B testable without code changes
- Analytics to track what works
What I didn't want:
- Popups that trigger on page load
- Exit-intent overlays (desperation tactics)
- Anything that interrupts reading
The Architecture Decision
I started by asking Claude to help me think through the approach:
I need a newsletter signup system for my Next.js blog. Requirements:
- Should be non-intrusive but visible
- Progressive (banner → modal)
- ConvertKit API integration
- Track which posts convert best
- Easy to A/B test copy and timing
What architecture would you recommend?
Claude suggested a three-tier approach:
Tier 1: Banner (always visible)
- Fixed position at bottom
- Dismissible but persistent
- Low commitment ("Get updates")
Tier 2: Inline CTAs (contextual)
- End of valuable blog posts
- Category-specific messaging
- "If you found this useful..."
Tier 3: Modal (triggered by engagement)
- Only after meaningful interaction
- Triggered by scroll depth or time on page
- Stronger value proposition
- Last resort before they leave
This made sense. Let's build it.
Day 1: ConvertKit Integration (2 hours)
Setting Up the API
First task: get the ConvertKit API working.
// lib/convertkit.ts
interface NewsletterSubscription {
email: string
firstName?: string
tags?: string[]
source?: string
}
export async function subscribeToNewsletter(
data: NewsletterSubscription
): Promise<{ success: boolean; message: string }> {
const CONVERTKIT_API_KEY = process.env.CONVERTKIT_API_KEY
const CONVERTKIT_FORM_ID = process.env.CONVERTKIT_FORM_ID
if (!CONVERTKIT_API_KEY || !CONVERTKIT_FORM_ID) {
throw new Error('ConvertKit credentials not configured')
}
try {
const response = await fetch(
`https://api.convertkit.com/v3/forms/${CONVERTKIT_FORM_ID}/subscribe`,
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
api_key: CONVERTKIT_API_KEY,
email: data.email,
first_name: data.firstName,
tags: data.tags,
fields: {
source: data.source || 'website',
},
}),
}
)
const result = await response.json()
if (response.ok) {
return { success: true, message: 'Successfully subscribed!' }
} else {
return { success: false, message: result.message || 'Subscription failed' }
}
} catch (error) {
console.error('ConvertKit subscription error:', error)
return { success: false, message: 'Something went wrong. Please try again.' }
}
}
Claude wrote this on the first try. I added error handling based on ConvertKit's API docs.
Building the API Route
Next.js API route to call from the frontend:
// app/api/newsletter/subscribe/route.ts
import { subscribeToNewsletter } from '@/lib/convertkit'
import { NextRequest, NextResponse } from 'next/server'
export async function POST(request: NextRequest) {
try {
const body = await request.json()
const { email, firstName, source } = body
if (!email || !isValidEmail(email)) {
return NextResponse.json(
{ success: false, message: 'Valid email is required' },
{ status: 400 }
)
}
const result = await subscribeToNewsletter({
email,
firstName,
source,
})
return NextResponse.json(result)
} catch (error) {
return NextResponse.json(
{ success: false, message: 'Server error' },
{ status: 500 }
)
}
}
function isValidEmail(email: string): boolean {
return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)
}
Tested with Postman. Worked perfectly.
Day 1: Building the Banner (1 hour)
The Component
I wanted the banner to be dismissible but remember the dismissal.
'use client'
import { useState, useEffect } from 'react'
import { X } from 'lucide-react'
export function NewsletterBanner() {
const [dismissed, setDismissed] = useState(true)
useEffect(() => {
const isDismissed = localStorage.getItem('newsletter-banner-dismissed')
const dismissedTime = isDismissed ? parseInt(isDismissed) : 0
const daysSinceDismissal = (Date.now() - dismissedTime) / (1000 * 60 * 60 * 24)
// Show again after 7 days
if (!isDismissed || daysSinceDismissal > 7) {
setDismissed(false)
}
}, [])
const handleDismiss = () => {
localStorage.setItem('newsletter-banner-dismissed', Date.now().toString())
setDismissed(true)
}
if (dismissed) return null
return (
<div className="newsletter-banner">
<div className="newsletter-banner-content">
<p>
<strong>Get weekly insights</strong> on building with AI and Excel →
</p>
<a href="#newsletter" className="banner-cta">
Subscribe
</a>
</div>
<button onClick={handleDismiss} className="banner-dismiss">
<X size={16} />
</button>
</div>
)
}
What Claude got right:
- localStorage for persistence
- Time-based re-showing (7 days)
- Clean component structure
What I adjusted:
- Changed copy to be more specific
- Made the CTA scroll to inline form instead of opening modal immediately
Day 1: The Modal (2 hours)
This was the most complex piece. I wanted:
- Beautiful design
- Smooth animations
- Accessible (keyboard navigation, focus trap)
- Smart triggering logic
The Modal Component
'use client'
import { useState, useEffect } from 'react'
import { X, Mail, ArrowRight } from 'lucide-react'
import { subscribeToNewsletter } from '@/lib/convertkit-client'
interface NewsletterModalProps {
trigger: 'scroll' | 'time' | 'manual'
delay?: number
}
export function NewsletterModal({ trigger, delay = 30000 }: NewsletterModalProps) {
const [isOpen, setIsOpen] = useState(false)
const [email, setEmail] = useState('')
const [firstName, setFirstName] = useState('')
const [isSubmitting, setIsSubmitting] = useState(false)
const [message, setMessage] = useState('')
useEffect(() => {
// Don't show if already subscribed or dismissed recently
const isSubscribed = localStorage.getItem('newsletter-subscribed')
const lastDismissed = localStorage.getItem('newsletter-modal-dismissed')
if (isSubscribed) return
if (lastDismissed) {
const daysSince = (Date.now() - parseInt(lastDismissed)) / (1000 * 60 * 60 * 24)
if (daysSince < 14) return // Wait 2 weeks
}
if (trigger === 'time') {
const timer = setTimeout(() => setIsOpen(true), delay)
return () => clearTimeout(timer)
}
if (trigger === 'scroll') {
const handleScroll = () => {
const scrolled = window.scrollY
const total = document.documentElement.scrollHeight - window.innerHeight
const percentage = (scrolled / total) * 100
if (percentage > 60) {
setIsOpen(true)
window.removeEventListener('scroll', handleScroll)
}
}
window.addEventListener('scroll', handleScroll)
return () => window.removeEventListener('scroll', handleScroll)
}
}, [trigger, delay])
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
setIsSubmitting(true)
setMessage('')
const result = await subscribeToNewsletter({
email,
firstName,
source: `modal-${trigger}`,
})
setIsSubmitting(false)
if (result.success) {
setMessage('Thanks for subscribing!')
localStorage.setItem('newsletter-subscribed', 'true')
setTimeout(() => setIsOpen(false), 2000)
} else {
setMessage(result.message)
}
}
const handleClose = () => {
localStorage.setItem('newsletter-modal-dismissed', Date.now().toString())
setIsOpen(false)
}
if (!isOpen) return null
return (
<>
<div className="modal-backdrop" onClick={handleClose} />
<div className="newsletter-modal">
<button onClick={handleClose} className="modal-close">
<X size={20} />
</button>
<div className="modal-icon">
<Mail size={32} />
</div>
<h2>Don't Miss the Next Post</h2>
<p>
Join 2,500+ builders getting weekly insights on Claude, Excel, and
building digital products.
</p>
<form onSubmit={handleSubmit}>
<input
type="text"
placeholder="First name (optional)"
value={firstName}
onChange={(e) => setFirstName(e.target.value)}
/>
<input
type="email"
placeholder="Your email"
value={email}
onChange={(e) => setEmail(e.target.value)}
required
/>
<button type="submit" disabled={isSubmitting}>
{isSubmitting ? 'Subscribing...' : 'Get Weekly Insights'}
<ArrowRight size={16} />
</button>
</form>
{message && <p className="modal-message">{message}</p>}
<p className="modal-privacy">No spam. Unsubscribe anytime.</p>
</div>
</>
)
}
Key features:
- Multiple trigger types (scroll depth, time on page, manual)
- Smart dismissal logic (14 days)
- Never shows to existing subscribers
- Tracks conversion source (
modal-scrollvsmodal-time) - Accessible form with clear CTAs
Claude generated 80% of this. I refined the trigger logic and added the source tracking.
Day 2: Making It Smart (1 hour)
Context-Aware Messaging
Different blog categories should have different messaging. I added a context prop:
interface NewsletterModalProps {
trigger: 'scroll' | 'time' | 'manual'
delay?: number
context?: 'excel' | 'ai' | 'claude' | 'general'
}
function getContextualCopy(context: string) {
const copies = {
excel: {
title: "Master Excel Like a Pro",
subtitle: "Join 2,500+ Excel users getting weekly tips, tricks, and advanced techniques.",
},
claude: {
title: "Build Better with Claude",
subtitle: "Weekly insights on AI-assisted development, Claude Code workflows, and real case studies.",
},
ai: {
title: "Stay Ahead in AI",
subtitle: "Practical AI techniques for builders, not theorists. New tools, workflows, and lessons learned.",
},
general: {
title: "Don't Miss the Next Post",
subtitle: "Join 2,500+ builders getting weekly insights on Claude, Excel, and building digital products.",
},
}
return copies[context] || copies.general
}
Now the modal shows different messaging based on what the reader is interested in.
The Styling Challenge
I wanted the modal to feel premium—not like a generic popup.
Asked Claude: "Design a newsletter modal that feels like a high-end product, not a spammy popup"
It generated beautiful CSS with:
- Smooth entrance animation (slide up + fade in)
- Backdrop blur effect
- Elegant spacing and typography
- Responsive design for mobile
- Focus states for accessibility
I tweaked the colors to match my brand, but the structure was perfect.
The Analytics Layer
I needed to know what's working. Added tracking:
// Track modal shown
if (trigger === 'scroll') {
trackEvent('newsletter_modal_shown', { trigger: 'scroll', percentage: 60 })
}
// Track subscription source
const result = await subscribeToNewsletter({
email,
firstName,
source: `modal-${trigger}`,
tags: [context, 'website'],
})
// Track conversion
if (result.success) {
trackEvent('newsletter_subscribed', {
source: `modal-${trigger}`,
context,
})
}
Now I can see:
- Which trigger (scroll vs time) converts better
- Which context messaging works best
- Which blog categories generate most subscribers
Implementation Across the Site
On Blog Posts (High Intent)
// In blog post layout
<NewsletterModal trigger="scroll" context="claude" />
Shows after 60% scroll depth. If they're reading that far, they're engaged.
On Landing Pages (Lower Intent)
// In landing page layout
<NewsletterModal trigger="time" delay={45000} context="general" />
Shows after 45 seconds. More patient timing for casual visitors.
Manual Trigger
// In banner or inline CTA
<button onClick={() => setModalOpen(true)}>
Subscribe to Newsletter
</button>
{modalOpen && <NewsletterModal trigger="manual" context="general" />}
User-initiated. Highest intent, best conversion rate.
Results After 2 Weeks
Before the system:
- ~20 new subscribers per month
- 0.5% conversion rate
- No data on what works
After the system:
- ~180 new subscribers first month (9x increase)
- 3.2% conversion rate overall
- Clear data: scroll trigger converts 2x better than time
- Claude & AI context messaging performs best (4.1% conversion)
The progressive approach worked. Non-intrusive but effective.
What I Learned
1. Progressive Engagement Works
Don't go straight to the hard ask. Banner → inline → modal creates a natural escalation that feels respectful.
2. Context Matters
Generic "subscribe for updates" performs poorly. Category-specific messaging ("Build Better with Claude") converts 3x better.
3. Timing Is Everything
Showing a modal after 60% scroll (engagement) converts 2.3x better than showing after 30 seconds (arbitrary timing).
4. Never Show Twice Unnecessarily
If someone dismissed it, wait 14 days. If they subscribed, never show again. Respect the decision.
5. Claude Accelerated Everything
What would have taken me 2-3 days of research, trial, and error took 6 hours with Claude. The modal component alone would've been a full day.
What Claude Did Well
API Integration: Generated working ConvertKit code immediately. No fumbling with docs.
Component Architecture: Suggested the three-tier approach (banner, inline, modal) without me thinking of it.
Accessibility: Added keyboard navigation, focus management, and ARIA labels without being asked.
Edge Cases: Handled dismissal logic, localStorage, and subscription checking automatically.
What I Had to Do Myself
Messaging: Claude wrote generic copy. I rewrote it to match my brand voice.
Design Taste: Claude's initial design was functional but bland. I refined colors, spacing, and animation timing.
Analytics Strategy: I decided what to track. Claude implemented it.
Conversion Optimization: The 60% scroll threshold came from testing, not Claude's suggestion.
The Code Lives On GitHub
Full implementation: [github.com/jmuller/kudutek-newsletter]
Includes:
- Complete modal component
- ConvertKit API wrapper
- Analytics tracking
- Context-aware messaging system
Start With the Banner
You don't need to build everything at once.
Week 1: Add a simple banner. Test if people click.
Week 2: Add an inline form at the end of your best posts.
Week 3: Build the modal with smart triggering.
Each layer compounds. By the end of the month, you have a complete system that actually converts—without annoying your readers.
Related: Building with Claude Series
- How I Built This Website with Claude Code - The complete website build including this newsletter system
- How Claude Helped Me Escape WordPress: A Migration Story - The 3-week migration from WordPress to Next.js
- Building Production Apps with Claude API: A Practical Guide - Error handling and API integration patterns
- Claude API Prompt Patterns That Actually Work - Patterns for consistent API responses
Official Resources
- Claude Code Documentation - Complete guide to Claude Code features
- Anthropic Documentation - Full API and model documentation
- API Console - Manage API keys and monitor usage
- ConvertKit API Documentation - Email marketing API integration guide
Building a newsletter system for your site? The code is open source. Take it, modify it, ship it.