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-scroll vs modal-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.



Official Resources


Building a newsletter system for your site? The code is open source. Take it, modify it, ship it.