You're sending emails. Good. But do you know if anyone reads them? Most SaaS teams ship transactional emails into a void — no open rates, no click data, no conversion tracking. They optimize landing pages obsessively but treat email like a fire-and-forget missile.
This guide covers how to build email analytics into a Next.js app: open tracking, click tracking, conversion attribution, and the privacy constraints you need to respect.
21%
Average open rate
SaaS transactional emails (industry median)
2.3%
Average click rate
Across transactional + onboarding emails
~40%
Apple MPP share
Opens auto-triggered by Mail Privacy Protection
Open tracking: the tracking pixel
Open tracking works by embedding a tiny, invisible image in your email. When the recipient's email client loads the image, your server logs the request. It's been the standard for 20+ years.
The tracking pixel API route
import { NextRequest, NextResponse } from "next/server";
import { db } from "@/lib/db";
// 1x1 transparent GIF (43 bytes)
const PIXEL = Buffer.from(
"R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7",
"base64"
);
export async function GET(req: NextRequest) {
const emailId = req.nextUrl.searchParams.get("id");
if (emailId) {
// Fire and forget — don't block the pixel response
db.emailEvent.create({
data: {
emailId,
event: "opened",
userAgent: req.headers.get("user-agent") ?? undefined,
ip: req.headers.get("x-forwarded-for")?.split(",")[0] ?? undefined,
timestamp: new Date(),
},
}).catch(() => {}); // Swallow errors silently
}
return new NextResponse(PIXEL, {
headers: {
"Content-Type": "image/gif",
"Cache-Control": "no-store, no-cache, must-revalidate",
"Pragma": "no-cache",
"Expires": "0",
},
});
}Embedding the pixel in React Email
import { Img } from "@react-email/components";
type TrackingPixelProps = {
emailId: string;
baseUrl: string;
};
export function TrackingPixel({ emailId, baseUrl }: TrackingPixelProps) {
return (
<Img
src={`${baseUrl}/api/track/open?id=${emailId}`}
width={1}
height={1}
alt=""
style={{ display: "block", height: "1px", width: "1px", overflow: "hidden" }}
/>
);
}Click tracking: redirect-through links
Click tracking replaces the original link URL with a tracking URL that redirects to the destination after logging the click. This gives you reliable engagement data that's not affected by image-blocking or privacy proxies.
The click redirect route
import { NextRequest, NextResponse } from "next/server";
import { db } from "@/lib/db";
export async function GET(req: NextRequest) {
const emailId = req.nextUrl.searchParams.get("id");
const linkId = req.nextUrl.searchParams.get("link");
const destination = req.nextUrl.searchParams.get("url");
if (!destination) {
return NextResponse.redirect(new URL("/", req.url));
}
// Validate destination URL (prevent open redirect vulnerability)
try {
const url = new URL(destination);
const allowedHosts = [
process.env.APP_DOMAIN,
"docs." + process.env.APP_DOMAIN,
];
if (!allowedHosts.includes(url.hostname)) {
return NextResponse.redirect(new URL("/", req.url));
}
} catch {
return NextResponse.redirect(new URL("/", req.url));
}
if (emailId && linkId) {
db.emailEvent.create({
data: {
emailId,
event: "clicked",
metadata: { linkId, destination },
userAgent: req.headers.get("user-agent") ?? undefined,
timestamp: new Date(),
},
}).catch(() => {});
}
return NextResponse.redirect(destination);
}A tracked link component
import { Link } from "@react-email/components";
type TrackedLinkProps = {
emailId: string;
linkId: string;
href: string;
baseUrl: string;
children: React.ReactNode;
style?: React.CSSProperties;
};
export function TrackedLink({
emailId,
linkId,
href,
baseUrl,
children,
style,
}: TrackedLinkProps) {
const trackingUrl = new URL("/api/track/click", baseUrl);
trackingUrl.searchParams.set("id", emailId);
trackingUrl.searchParams.set("link", linkId);
trackingUrl.searchParams.set("url", href);
return (
<Link href={trackingUrl.toString()} style={style}>
{children}
</Link>
);
}Conversion tracking: closing the loop
Opens and clicks tell you about engagement. Conversions tell you about revenue. The pattern: tag each email with a unique ID, pass it through clicks as a UTM or query parameter, and attribute downstream actions.
import { db } from "@/lib/db";
// Call this when a user completes a target action
// (e.g., finishes onboarding, upgrades plan, completes purchase)
export async function trackEmailConversion({
userId,
action,
emailId,
revenue,
}: {
userId: string;
action: string;
emailId?: string;
revenue?: number;
}) {
// If emailId is provided directly (from query param)
if (emailId) {
await db.emailEvent.create({
data: {
emailId,
event: "converted",
metadata: { action, revenue },
timestamp: new Date(),
},
});
return;
}
// Otherwise, attribute to most recent email within 7-day window
const recentEmail = await db.emailLog.findFirst({
where: {
userId,
sentAt: { gte: new Date(Date.now() - 7 * 24 * 60 * 60 * 1000) },
},
orderBy: { sentAt: "desc" },
});
if (recentEmail) {
await db.emailEvent.create({
data: {
emailId: recentEmail.id,
event: "converted",
metadata: { action, revenue, attribution: "last-touch" },
timestamp: new Date(),
},
});
}
}Use last-touch attribution within a 7-day window for transactional emails. It's not perfect, but it's good enough to answer the question: “Did this email contribute to the conversion?”
The data model
You need two tables: one for email sends, one for events (opens, clicks, conversions). Keep them separate — one email can have many events.
model EmailLog {
id String @id @default(cuid())
userId String
template String // "welcome", "password-reset", etc.
to String
subject String
sentAt DateTime @default(now())
events EmailEvent[]
@@index([userId, sentAt])
@@index([template, sentAt])
}
model EmailEvent {
id String @id @default(cuid())
emailId String
email EmailLog @relation(fields: [emailId], references: [id])
event String // "opened", "clicked", "converted", "bounced"
metadata Json? // { linkId, destination, revenue, etc. }
userAgent String?
ip String?
timestamp DateTime @default(now())
@@index([emailId, event])
@@index([event, timestamp])
}Aggregation queries for dashboards
Raw events are useless without aggregation. Here are the queries that power a useful email analytics dashboard:
import { db } from "@/lib/db";
// Open rate by template (last 30 days)
export async function getOpenRateByTemplate() {
const thirtyDaysAgo = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000);
const results = await db.emailLog.groupBy({
by: ["template"],
where: { sentAt: { gte: thirtyDaysAgo } },
_count: { id: true },
});
const openCounts = await db.emailEvent.groupBy({
by: ["emailId"],
where: {
event: "opened",
timestamp: { gte: thirtyDaysAgo },
},
});
// Calculate rates per template...
return results.map((r) => ({
template: r.template,
sent: r._count.id,
// Deduplicate opens per email (one open per email, not per pixel load)
uniqueOpens: openCounts.filter((o) => /* match template */).length,
}));
}
// Click-through rate for a specific email campaign
export async function getClickRate(template: string, days = 30) {
const since = new Date(Date.now() - days * 24 * 60 * 60 * 1000);
const [sent, clicked] = await Promise.all([
db.emailLog.count({
where: { template, sentAt: { gte: since } },
}),
db.emailEvent.count({
where: {
event: "clicked",
email: { template, sentAt: { gte: since } },
},
}),
]);
return { sent, clicked, rate: sent > 0 ? clicked / sent : 0 };
}Privacy constraints you must respect
Email tracking has real privacy implications. Here's what you need to handle:
- Disclose tracking in your privacy policy
- Honor unsubscribe requests immediately
- Aggregate data — report on rates, not individuals
- Delete tracking data after 90 days
- Use tracking for product improvement, not surveillance
- Track without disclosure (GDPR violation)
- Use pixel tracking for individual user surveillance
- Store IP addresses longer than necessary
- Share tracking data with third parties
- Track users who have opted out of analytics
When to use a third-party service instead
Building your own tracking is worthwhile when you need full control over data, want to avoid vendor lock-in, or are sending fewer than 50K emails/month. Use a service when:
- You need deliverability analytics (bounce rates, spam complaints) — these require feedback loops with ISPs that services like Resend, Postmark, or SendGrid handle for you.
- You need inbox placement testing — services like Resend have built-in deliverability dashboards.
- You're sending at scale (100K+ emails/month) — the infrastructure for reliable pixel serving and click redirects under load is non-trivial.
Most email providers give you open and click tracking out of the box. The custom approach in this guide is for teams that want ownership of their data or need conversion attribution that providers don't offer.
Implementation checklist
- Create
EmailLogandEmailEventtables - Log every email send with a unique ID
- Add tracking pixel route (
/api/track/open) - Add click redirect route (
/api/track/click) with URL allowlist - Create
TrackingPixelandTrackedLinkcomponents - Add conversion attribution for key user actions
- Build aggregation queries for your dashboard
- Document tracking in your privacy policy
- Set up data retention (auto-delete events older than 90 days)
You don't need a full analytics suite on day one. Start with click tracking (the most reliable signal), add conversion attribution for your highest-value emails, and expand from there.