Code Tips11 min read

Email Analytics: Tracking Opens, Clicks, and Conversions in Next.js

Build email analytics into your Next.js app: tracking pixels, click-through redirects, conversion attribution, and privacy-first patterns. No third-party SDK required.

R

React Emails Pro

March 6, 2026

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.

We'll build everything with App Router API routes and React Email. No third-party analytics SDK required — though we'll cover when to use one.

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

app/api/track/open/route.ts
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

emails/components/tracking-pixel.tsx
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" }}
    />
  );
}
Apple Mail Privacy Protection pre-fetches all images regardless of whether the user opens the email. This means ~40% of your “opens” may be phantom opens. Never use open rates as your only success metric.

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

app/api/track/click/route.ts
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);
}
Security critical: Always validate the destination URL against an allowlist. Without this, your tracking endpoint becomes an open redirect — a phishing vector that will get your domain flagged.
emails/components/tracked-link.tsx
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.

lib/email/track-conversion.ts
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(),
      },
    });
  }
}
Key takeaway

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.

prisma/schema.prisma
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:

lib/email/analytics.ts
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:

Do
  • 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
Don't
  • 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
GDPR note: Under GDPR, tracking pixels in transactional emails may require legitimate interest documentation. Marketing emails require explicit consent. Consult your legal team for your specific jurisdiction.

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

  1. Create EmailLog and EmailEvent tables
  2. Log every email send with a unique ID
  3. Add tracking pixel route (/api/track/open)
  4. Add click redirect route (/api/track/click) with URL allowlist
  5. Create TrackingPixel and TrackedLink components
  6. Add conversion attribution for key user actions
  7. Build aggregation queries for your dashboard
  8. Document tracking in your privacy policy
  9. 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.

R

React Emails Pro

Team

Building production-ready email templates with React Email. Writing about transactional email best practices, deliverability, and developer tooling.

Production-ready templates

Pick from 9 template packs built with React Email. One-time purchase, lifetime updates, tested across every major email client.

Browse all templates