Email Tracking Pattern

Track email opens and link clicks to measure engagement and improve email effectiveness.

Overview#

Email tracking helps you understand how recipients interact with your emails. Open tracking uses invisible pixels, while click tracking uses redirect URLs. This data enables better targeting and content optimization.

When to use:

  • Measuring email campaign effectiveness
  • A/B testing email content
  • Identifying engaged users
  • Triggering follow-up actions
  • Building engagement analytics

Key features:

  • Open tracking with pixels
  • Click tracking with redirects
  • Per-email analytics
  • Privacy-conscious options
  • Database storage

Code Example#

Database Schema#

1// prisma/schema.prisma 2model EmailQueue { 3 id String @id @default(cuid()) 4 to String 5 subject String 6 template String 7 data String 8 status EmailStatus @default(PENDING) 9 sentAt DateTime? 10 openedAt DateTime? 11 createdAt DateTime @default(now()) 12 13 clicks EmailClick[] 14} 15 16model EmailClick { 17 id String @id @default(cuid()) 18 emailId String 19 email EmailQueue @relation(fields: [emailId], references: [id]) 20 link String 21 clickedAt DateTime @default(now()) 22 userAgent String? 23 ip String? 24 25 @@index([emailId]) 26}

Open Tracking Endpoint#

1// app/api/email/track/[id]/route.ts 2import { prisma } from '@/lib/db' 3import { NextRequest, NextResponse } from 'next/server' 4 5// 1x1 transparent GIF pixel 6const TRACKING_PIXEL = Buffer.from( 7 'R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7', 8 'base64' 9) 10 11export async function GET( 12 request: NextRequest, 13 { params }: { params: { id: string } } 14) { 15 const emailId = params.id 16 17 // Record open (don't await to respond quickly) 18 prisma.emailQueue.update({ 19 where: { id: emailId }, 20 data: { 21 openedAt: new Date() 22 } 23 }).catch(console.error) 24 25 // Return tracking pixel 26 return new NextResponse(TRACKING_PIXEL, { 27 headers: { 28 'Content-Type': 'image/gif', 29 'Cache-Control': 'no-store, no-cache, must-revalidate', 30 'Pragma': 'no-cache' 31 } 32 }) 33}

Click Tracking Endpoint#

1// app/api/email/click/[id]/route.ts 2import { prisma } from '@/lib/db' 3import { NextRequest, NextResponse } from 'next/server' 4 5export async function GET( 6 request: NextRequest, 7 { params }: { params: { id: string } } 8) { 9 const emailId = params.id 10 const searchParams = request.nextUrl.searchParams 11 const url = searchParams.get('url') 12 13 if (!url) { 14 return NextResponse.redirect(new URL('/', request.url)) 15 } 16 17 // Validate URL to prevent open redirect vulnerability 18 let targetUrl: URL 19 try { 20 targetUrl = new URL(url) 21 const allowedHosts = [ 22 process.env.NEXT_PUBLIC_APP_URL, 23 // Add other allowed domains 24 ].filter(Boolean).map(u => new URL(u!).host) 25 26 if (!allowedHosts.includes(targetUrl.host)) { 27 console.warn('Blocked redirect to:', targetUrl.host) 28 return NextResponse.redirect(new URL('/', request.url)) 29 } 30 } catch { 31 return NextResponse.redirect(new URL('/', request.url)) 32 } 33 34 // Record click 35 await prisma.emailClick.create({ 36 data: { 37 emailId, 38 link: url, 39 userAgent: request.headers.get('user-agent') ?? undefined, 40 ip: request.ip ?? request.headers.get('x-forwarded-for') ?? undefined 41 } 42 }).catch(console.error) 43 44 return NextResponse.redirect(targetUrl) 45}

Add Tracking to Emails#

1// lib/email/tracking.ts 2const APP_URL = process.env.NEXT_PUBLIC_APP_URL 3 4export function addTrackingPixel(emailId: string): string { 5 return `<img src="${APP_URL}/api/email/track/${emailId}" width="1" height="1" style="display:none" alt="" />` 6} 7 8export function wrapLinksForTracking( 9 html: string, 10 emailId: string 11): string { 12 // Match href attributes 13 const linkRegex = /href="(https?:\/\/[^"]+)"/g 14 15 return html.replace(linkRegex, (match, url) => { 16 // Don't track unsubscribe links 17 if (url.includes('unsubscribe')) { 18 return match 19 } 20 21 const trackingUrl = `${APP_URL}/api/email/click/${emailId}?url=${encodeURIComponent(url)}` 22 return `href="${trackingUrl}"` 23 }) 24} 25 26export function addTracking(html: string, emailId: string): string { 27 // Add tracking pixel before </body> 28 let tracked = html.replace( 29 '</body>', 30 `${addTrackingPixel(emailId)}</body>` 31 ) 32 33 // Wrap links 34 tracked = wrapLinksForTracking(tracked, emailId) 35 36 return tracked 37}

Email Sending with Tracking#

1// lib/email/service.ts 2import { render } from '@react-email/render' 3import { resend } from '@/lib/email' 4import { prisma } from '@/lib/db' 5import { addTracking } from './tracking' 6 7export async function sendTrackedEmail( 8 to: string, 9 subject: string, 10 Template: React.ComponentType<any>, 11 props: any 12) { 13 // Create email record first 14 const email = await prisma.emailQueue.create({ 15 data: { 16 to, 17 subject, 18 template: Template.name, 19 data: JSON.stringify(props), 20 status: 'PENDING' 21 } 22 }) 23 24 try { 25 // Render template 26 let html = await render(Template(props)) 27 28 // Add tracking 29 html = addTracking(html, email.id) 30 31 // Send 32 await resend.emails.send({ 33 from: `${process.env.EMAIL_FROM_NAME} <${process.env.EMAIL_FROM_ADDRESS}>`, 34 to, 35 subject, 36 html 37 }) 38 39 // Update status 40 await prisma.emailQueue.update({ 41 where: { id: email.id }, 42 data: { status: 'SENT', sentAt: new Date() } 43 }) 44 45 return email 46 } catch (error) { 47 await prisma.emailQueue.update({ 48 where: { id: email.id }, 49 data: { 50 status: 'FAILED', 51 error: error instanceof Error ? error.message : 'Unknown error' 52 } 53 }) 54 throw error 55 } 56}

Analytics Functions#

1// lib/email/analytics.ts 2import { prisma } from '@/lib/db' 3 4export async function getEmailStats(emailId: string) { 5 const email = await prisma.emailQueue.findUnique({ 6 where: { id: emailId }, 7 include: { clicks: true } 8 }) 9 10 if (!email) return null 11 12 return { 13 id: email.id, 14 to: email.to, 15 subject: email.subject, 16 sentAt: email.sentAt, 17 opened: !!email.openedAt, 18 openedAt: email.openedAt, 19 clickCount: email.clicks.length, 20 clicks: email.clicks.map(c => ({ 21 link: c.link, 22 clickedAt: c.clickedAt 23 })) 24 } 25} 26 27export async function getCampaignStats(template: string, dateRange?: { 28 from: Date 29 to: Date 30}) { 31 const where = { 32 template, 33 status: 'SENT' as const, 34 ...(dateRange && { 35 sentAt: { 36 gte: dateRange.from, 37 lte: dateRange.to 38 } 39 }) 40 } 41 42 const [total, opened, clicked] = await Promise.all([ 43 prisma.emailQueue.count({ where }), 44 prisma.emailQueue.count({ 45 where: { ...where, openedAt: { not: null } } 46 }), 47 prisma.emailQueue.count({ 48 where: { 49 ...where, 50 clicks: { some: {} } 51 } 52 }) 53 ]) 54 55 return { 56 total, 57 opened, 58 clicked, 59 openRate: total > 0 ? (opened / total) * 100 : 0, 60 clickRate: total > 0 ? (clicked / total) * 100 : 0, 61 clickToOpenRate: opened > 0 ? (clicked / opened) * 100 : 0 62 } 63} 64 65export async function getTopLinks(template: string, limit = 10) { 66 const clicks = await prisma.emailClick.groupBy({ 67 by: ['link'], 68 where: { 69 email: { template } 70 }, 71 _count: { link: true }, 72 orderBy: { _count: { link: 'desc' } }, 73 take: limit 74 }) 75 76 return clicks.map(c => ({ 77 url: c.link, 78 clicks: c._count.link 79 })) 80}

Privacy-Conscious Implementation#

1// lib/email/tracking.ts 2 3// Option to disable tracking per user 4export async function shouldTrackEmail(userId: string): Promise<boolean> { 5 const user = await prisma.user.findUnique({ 6 where: { id: userId }, 7 select: { emailTrackingEnabled: true } 8 }) 9 10 return user?.emailTrackingEnabled ?? true 11} 12 13// Anonymize tracking data 14export async function anonymizeTrackingData(olderThan: Date) { 15 await prisma.emailClick.updateMany({ 16 where: { 17 clickedAt: { lt: olderThan } 18 }, 19 data: { 20 ip: null, 21 userAgent: null 22 } 23 }) 24} 25 26// GDPR data export 27export async function exportUserEmailData(userEmail: string) { 28 const emails = await prisma.emailQueue.findMany({ 29 where: { to: userEmail }, 30 include: { clicks: true } 31 }) 32 33 return emails.map(email => ({ 34 id: email.id, 35 subject: email.subject, 36 sentAt: email.sentAt, 37 openedAt: email.openedAt, 38 clicks: email.clicks.map(c => ({ 39 link: c.link, 40 clickedAt: c.clickedAt 41 })) 42 })) 43}

Usage Instructions#

  1. Set up database: Add EmailQueue and EmailClick models
  2. Create tracking endpoints: Implement pixel and click redirect routes
  3. Wrap email content: Add tracking to HTML before sending
  4. Record events: Track opens and clicks in the database
  5. Analyze data: Build dashboards to visualize engagement

Best Practices#

  1. Validate redirect URLs - Prevent open redirect vulnerabilities
  2. Respond quickly - Don't slow down pixel/redirect responses
  3. Handle errors silently - Don't break emails if tracking fails
  4. Respect privacy - Offer opt-out and anonymize old data
  5. Clean old data - Remove or anonymize tracking data over time
  6. Don't track sensitive links - Skip unsubscribe and password reset links
  7. Monitor for abuse - Watch for unusual patterns
  8. Be transparent - Disclose tracking in privacy policy