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#
- Set up database: Add EmailQueue and EmailClick models
- Create tracking endpoints: Implement pixel and click redirect routes
- Wrap email content: Add tracking to HTML before sending
- Record events: Track opens and clicks in the database
- Analyze data: Build dashboards to visualize engagement
Best Practices#
- Validate redirect URLs - Prevent open redirect vulnerabilities
- Respond quickly - Don't slow down pixel/redirect responses
- Handle errors silently - Don't break emails if tracking fails
- Respect privacy - Offer opt-out and anonymize old data
- Clean old data - Remove or anonymize tracking data over time
- Don't track sensitive links - Skip unsubscribe and password reset links
- Monitor for abuse - Watch for unusual patterns
- Be transparent - Disclose tracking in privacy policy
Related Patterns#
- Transactional Email - Basic email sending
- Email Templates - React Email components
- Email Queues - Background processing