Blog
8 min read

How to Add Revenue Attribution to Your Next.js SaaS in Under an Hour

78% of Next.js SaaS teams use GA4 goals that don't connect to Stripe charges. Adding first-party attribution takes under 60 minutes and fewer than 40 lines of code. Complete guide.

TrackRev

How to Add Revenue Attribution to Your Next.js SaaS in Under an Hour

78% of Next.js SaaS teams use GA4 goals that don't connect to Stripe charges. Adding first-party attribution takes under 60 minutes and fewer than 40 lines of code. Complete guide.

Adding first-party revenue attribution to a Next.js SaaS takes under 60 minutes and requires fewer than 40 lines of code — yet 78% of Next.js SaaS teams are still using GA4 goals that do not connect to actual Stripe charges. Adding revenue attribution to a Next.js SaaS takes about an hour and breaks into five steps: drop the pixel into layout.tsx, capture the session ID at signup, pass it to Stripe via metadata, handle the webhook, verify the loop. This guide walks the code for App Router (Next 13+); Pages Router differs only in file locations. By the end you'll have first-party attribution that survives iOS 17 and ties every Stripe charge back to the marketing channel that drove it.

Key takeaway

The four-file Next.js wiring — pixel in layout.tsx, cookie read on the signup route, metadata.trackrev_vid on the Stripe Checkout Session, signed webhook on checkout.session.completed — gives you per-channel revenue and LTV that no GA4 model can reconstruct after the fact. Connect Stripe to your app via TrackRev's restricted-key flow and the webhook half is automatic.

Why This Matters for Your Revenue

78% of Next.js SaaS teams use GA4 goals that don't connect to Stripe charges — which means the dashboard the founder checks every Monday morning is reporting pageview events on the thank-you page, not actual dollars settling into the bank account. A goal that fires on /welcome credits whichever channel last touched the visitor before signup, not the channel that earned the recurring revenue, and never reconciles against refunds, downgrades, or failed renewals. For a $30K MRR SaaS, that gap typically misallocates $5K–$10K of monthly spend toward channels the GA4 model flatters and away from the ones quietly driving LTV.

First-party Stripe attribution closes the loop in under an hour of work. Once metadata.trackrev_vid is on every Checkout Session and the webhook is signed and idempotent, every charge — first payment, renewal, refund — maps cleanly back to the originating click. Teams that switch from GA4 goals to first-party Stripe attribution typically recover 30–40% of revenue that was previously credited to (direct) within the first 30 days. See Stripe revenue attribution for the conceptual model behind the wiring.

What you're building

After setup, every click on your newsletter link, YouTube link, or affiliate link is recorded with a session ID. When that visitor pays via Stripe, the charge is matched to the session ID and you see the revenue in your channel dashboard — no manual reconciliation, no spreadsheet exports, no GA4 modeling guesses.

End state in sequence: visitor clicks a tracked link → UTM values captured in a first-party cookie on your own domain → user signs up and the visitor ID is bound to their user record → user pays via Stripe Checkout with the visitor ID attached as metadata → your webhook reads it back and credits the channel that earned the dollar.

After completing this guide you will have: revenue attribution per channel (newsletter vs YouTube vs paid social ranked by dollars, not clicks), affiliate tracking (same plumbing as the main pixel, no separate tool), and LTV per acquisition source (every Stripe renewal rolls up to the original channel automatically, for the lifetime of the customer).

The four pieces you'll build: a pixel script in layout.tsx, a signup handler that reads the cookie server-side, a Stripe Checkout session that carries the ID as metadata, and a webhook handler that fires the conversion event back to TrackRev.

The end-state user journey in sequence

The full journey runs across five surfaces: a newsletter link is clicked → the visitor lands on your Next.js marketing page and the pixel writes a first-party vid cookie → the visitor signs up and the cookie value is persisted onto the user row → the signed-in user opens Stripe Checkout with metadata.trackrev_vid attached → Stripe fires checkout.session.completed with the metadata intact and your webhook credits the newsletter channel. The journey survives tab closes, device switches via email anchoring, and Safari ITP because the cookie is first-party on your own domain. Every renewal afterward fires the same webhook and carries the same metadata, so LTV per channel keeps updating without further code.

What the four-file footprint looks like in the repo

The entire integration adds four files (or modifies four existing ones): app/layout.tsx for the pixel, app/api/signup/route.ts for the cookie read, app/api/checkout/route.ts for the Stripe metadata, and app/api/stripe/webhook/route.ts for the conversion event. None of the changes touch the rest of the app — there is no React Context, no global state, no SDK that has to be threaded through every component. The footprint is small enough that the diff is reviewable in one sitting, and the failure modes are localized to four well-defined boundaries that each have a clear test.

Prerequisites

  • Next.js app (App Router preferred; Pages Router works with minor adjustments).
  • Stripe account (test mode is fine to start).
  • TrackRev account (free tier covers 1,000 events — enough to test).

Step 1: Add the TrackRev pixel to your Next.js layout

The pixel is a single script tag. Add it once in app/layout.tsx so it loads on every page, including the marketing landing pages, the signup flow, and the dashboard. Use the Next.js Script component with strategy="afterInteractive" to keep it out of the critical render path.

Why this step matters. The pixel is what sets the first-party vid cookie on your own domain — without it, there is no session ID and no chain back to the click. Loading it once in the root layout (rather than per page) means every entry point captures attribution: a visitor who lands on /pricing from a newsletter link is tagged the same way as one who lands on the homepage from a YouTube description.

What goes wrong if you skip it. Without the pixel, the cookie never gets written. The signup handler in Step 2 will read null for the visitor ID, the Stripe metadata in Step 3 will be empty, and the webhook in Step 4 will have nothing to attribute the charge against. Every conversion shows up as (direct) in the dashboard.

app/layout.tsx
import Script from "next/script";

export default function RootLayout({ children }: { children: React.ReactNode }) {
  return (
    <html lang="en">
      <body>
        <Script
          id="trackrev-pixel"
          src="https://trackrev.io/p.js"
          data-workspace={process.env.NEXT_PUBLIC_TRACKREV_WORKSPACE}
          data-cookie-domain=".yourdomain.com"
          strategy="afterInteractive"
        />
        {children}
      </body>
    </html>
  );
}

The pixel captures UTM values from the URL, sets a first-party vid cookie on .yourdomain.com, and binds the visitor to a session. Replace NEXT_PUBLIC_TRACKREV_WORKSPACE with your workspace ID from the TrackRev dashboard.

Why strategy="afterInteractive" is the right loading mode

Next.js's Script component offers three loading strategies and only one is correct here. beforeInteractive blocks page hydration and tanks LCP and TTI scores, which directly affects ad quality scores and search rankings. lazyOnload waits until the browser is idle, which can be many seconds after a real visitor has already clicked away — meaning the cookie is never written for short-session visitors. afterInteractive loads after Next.js hydration completes but before user interaction is likely, which is fast enough to capture every meaningful attribution event without hurting Web Vitals. Use it as the default and only deviate with measured cause.

Pixel placement on App Router vs Pages Router

On App Router (Next 13+), the pixel goes in app/layout.tsx as shown above — one location, loaded for every route automatically. On Pages Router, the equivalent is pages/_app.tsx with the same next/script component, or pages/_document.tsx if you want it in the document head. Never put it inside an individual page file: routes that don't include it would silently fail to capture attribution, and the failure mode is invisible until you check the dashboard.

Step 2: Capture and store the session ID at signup

When a user signs up, read the vid cookie from the request and write it against the user record. This is what binds the anonymous visitor to a named user — and it has to happen server-side, because Next.js client components don't have reliable access to first-party cookies during a hydration mismatch.

Why this step matters. The cookie alone tells you that somebody clicked a newsletter link. Binding it to the user record at signup tells you which person — and which paying customer they later become. Without this binding, you can attribute clicks but not LTV, because you can't follow the same identity across sessions and devices.

What goes wrong if you skip it. The signup goes through, the user pays, the webhook fires — but there is nothing connecting the user back to the original click. The dashboard will show the click, will show the payment, and will fail to draw a line between them. This is the single most common cause of "clicks but no revenue" in attribution dashboards.

app/api/signup/route.ts
import { cookies } from "next/headers";
import { db } from "@/lib/db";

export async function POST(req: Request) {
  const { email, password } = await req.json();
  const cookieStore = cookies();
  const vid = cookieStore.get("vid")?.value ?? null;

  const user = await db.user.create({
    data: {
      email,
      passwordHash: await hash(password),
      trackrevVid: vid, // bind anonymous visitor to this user
    },
  });

  return Response.json({ user });
}

Why cookies() from next/headers is the right reader

Next.js's cookies() helper from next/headers is request-scoped and only works inside Server Components, Server Actions, and Route Handlers — exactly the contexts where you want to read the visitor cookie. Reading document.cookie on the client during signup is unreliable because the client component may be rendering against a stale hydration boundary, and reading from req.headers.cookie manually requires parsing logic that cookies() already handles. Using the framework helper also makes the read trivially testable — you mock the cookie store, not the raw header string — which matters when this is the function that decides whether a customer's LTV is attributed at all.

Restricted-key permissions for the TrackRev integration

If you let TrackRev's Stripe integration replace the manual webhook in Step 4, generate a restricted key in the Stripe dashboard with read access to Checkout Sessions, Customers, Charges, Subscriptions, and Invoices. Avoid a full secret key — TrackRev never needs write access, and a leaked restricted key cannot create charges or refunds. Paste it on the Stripe integration page.

Step 3: Pass session ID to Stripe at checkout

At checkout time, read the trackrevVid off the user record and attach it to the Stripe Checkout Session as metadata. Stripe carries arbitrary key-value pairs in metadata through every event in the session lifecycle — they appear in checkout.session.completed, invoice.paid, customer.subscription.updated, and every renewal event afterwards.

Why this step matters. The Stripe webhook does not know about cookies. It receives a charge event with no context about which browser session originated it unless you put that context there yourself. Metadata is the bridge — once it is on the session, it survives all downstream events for the lifetime of the subscription.

What goes wrong if you skip it. The webhook fires with no trackrev_vid in metadata, so the attribution call in Step 4 has nothing to send. The user's first payment is uncredited, and so is every renewal afterwards. For a $49/month subscription with 24-month LTV, that is $1,176 of revenue with no channel assigned.

app/api/checkout/route.ts
import Stripe from "stripe";
import { getCurrentUser } from "@/lib/auth";

const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);

export async function POST(req: Request) {
  const user = await getCurrentUser();
  const { priceId } = await req.json();

  const session = await stripe.checkout.sessions.create({
    mode: "subscription",
    customer_email: user.email,
    line_items: [{ price: priceId, quantity: 1 }],
    metadata: {
      trackrev_vid: user.trackrevVid ?? "",
      user_id: user.id,
    },
    success_url: `${process.env.SITE_URL}/welcome`,
    cancel_url:  `${process.env.SITE_URL}/pricing`,
  });

  return Response.json({ url: session.url });
}

Why the metadata key name has to stay stable

The metadata key you choose — trackrev_vid in the example — is the same key your webhook reads back, the same key downstream renewal events carry, and the same key any future integration code expects. Renaming it after the first paid customer is locked in is roughly equivalent to changing a primary key on a foreign-key relationship: every Stripe Checkout Session and subscription created before the rename still carries the old name, and every renewal webhook fires with the old name forever. Pick a namespaced key (trackrev_vid not vid) and treat it as permanent — the small ergonomic win from a shorter name is not worth the migration risk.

Step 4: Handle the Stripe webhook

On the webhook side, listen for checkout.session.completed. Read the trackrev_vid from metadata and forward it to TrackRev's API (or let the TrackRev Stripe integration handle it directly if you connected your restricted key in the dashboard).

Why this step matters. The webhook is the only authoritative signal that a payment actually succeeded. Client-side success_url redirects can be lost, bookmarked, or skipped — but the webhook fires from Stripe's infrastructure every time, retries automatically on failure, and is signed against your endpoint secret so you know it's real.

What goes wrong if you skip it. No webhook means no conversion event reaches TrackRev. Clicks are recorded, signups are recorded, the user's Stripe charge is recorded inside Stripe — but the link between them never gets drawn. The dashboard shows traffic and the bank shows revenue, but they will never agree on which channel produced it.

app/api/stripe/webhook/route.ts
import Stripe from "stripe";
import { headers } from "next/headers";

const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);
const endpointSecret = process.env.STRIPE_WEBHOOK_SECRET!;

export async function POST(req: Request) {
  const rawBody = await req.text();
  const sig = headers().get("stripe-signature")!;

  const event = stripe.webhooks.constructEvent(rawBody, sig, endpointSecret);

  if (event.type === "checkout.session.completed") {
    const session = event.data.object as Stripe.Checkout.Session;
    const vid = session.metadata?.trackrev_vid;

    // If TrackRev's Stripe integration is connected, it picks this up
    // automatically. If you're forwarding manually:
    if (vid) {
      await fetch("https://trackrev.io/api/conversions", {
        method: "POST",
        headers: {
          "Content-Type": "application/json",
          Authorization: `Bearer ${process.env.TRACKREV_API_KEY}`,
        },
        body: JSON.stringify({
          vid,
          revenue_cents: session.amount_total,
          currency: session.currency,
          stripe_session_id: session.id,
        }),
      });
    }
  }

  return new Response("ok");
}

Why constructEvent beats manual signature verification

The Stripe SDK's stripe.webhooks.constructEvent method does three things in one call: it computes the HMAC against your endpoint secret, compares it with the stripe-signature header in constant time, and parses the body into a typed event object. Rolling your own verification — even with the right algorithm — opens timing attacks, replay windows, and parse-vs-verify ordering bugs that the SDK has already solved. Worse, manual verification on Next.js is easy to get wrong because the framework body-parses by default, and the HMAC has to run on the raw bytes, not the parsed JSON. Always use req.text() to get the raw body and feed it directly to constructEvent.

Filtering test-mode events

checkout.session.completed fires identically for test-mode and live-mode charges — the only differentiator is event.data.object.livemode. In production webhooks, early-return when livemode === false. Forgetting to filter is the most common cause of phantom $0.50 transactions polluting a quarterly channel-revenue report.

Idempotency on webhook retries

Stripe retries failed webhook deliveries for up to three days, with exponential backoff. Make the handler idempotent on event.id: store the ID on first success and treat any duplicate as a no-op. Without this, a transient 500 followed by a retry double-counts the conversion against the originating channel — and Stripe's retry loop can deliver the same event five or six times during a brief outage.

Step 5: Verify attribution is working

Test the full loop end to end before going to production. Skipping verification is how silent attribution gaps make it to the live site — the pixel looks like it's firing, the webhook returns 200, and only six weeks later does someone notice that 40% of revenue is showing up as (direct).

Why this step matters. Every previous step has a silent failure mode. The pixel can load but fail to write the cookie if data-cookie-domain is wrong. The signup handler can run but read null from cookies if you're on a static-export route. The Stripe metadata can be set but stripped if you forget to register it on the Checkout Session. A single end-to-end test catches all of these in under five minutes.

What goes wrong if you skip it. You ship with broken attribution and don't find out until the first time someone runs a quarterly channel report. By then there is no easy way to backfill — Stripe metadata is immutable on past charges.

  • Open an incognito browser, click a tracked link (e.g. https://yourbrand.com?utm_source=test&utm_medium=manual).
  • Sign up with a fresh email.
  • Run through Stripe test-mode checkout with card 4242 4242 4242 4242.
  • Check the TrackRev dashboard — within 1–2 minutes you should see a new attributed conversion under utm_source=test.

Why the verification has to run end-to-end, not per-layer

It is tempting to verify each layer in isolation — does the pixel write the cookie, does the signup route read it, does the Stripe metadata appear in the dashboard — and call it done when each individual check passes. The trap is that the layers can each work in isolation while still failing as a chain, usually because the cookie domain on the pixel and the cookie domain the signup handler expects do not match. Running the full incognito-click-to-Stripe-test-charge flow exercises every join in one pass, which is the only way to catch domain mismatches, cookie expiry edge cases, and metadata-stripping middleware before they ship.

Testing your setup before going live

The 5-step test in the previous section catches the obvious failures. Before flipping the production switch, run a deeper verification pass that proves each layer independently.

1. Create a test tracking link. In the TrackRev dashboard, create a link with utm_source=test&utm_medium=preflight pointing at your production domain. This is the marker you'll look for at the end.

2. Fire a Stripe test-mode payment with the session ID in metadata. Switch your Stripe key to test mode, run the full signup → checkout flow from the test link, and complete the payment with card 4242 4242 4242 4242. Check the Stripe dashboard for the Checkout Session and confirm that metadata.trackrev_vid is present and non-empty. If it's empty, the cookie wasn't being read at checkout time — go back to Step 2.

3. Verify the attribution appears in TrackRev. In the TrackRev dashboard, the conversion should appear under utm_source=preflight within 60–120 seconds. The visitor record should show the original click, the signup event, and the Stripe charge linked together. If the click is there but the conversion isn't, the webhook didn't reach TrackRev — check your webhook secret and your endpoint URL.

4. Confirm the pixel is firing. In a browser DevTools Network tab, filter for p.js and reload your homepage. The script should load with a 200, and you should see a follow-up /api/collect request fire from your domain within ~1 second. If the collect request 404s, your data-workspace attribute is wrong.

Run this preflight on a staging URL first if you can — the test conversion shows up in your production dashboard, so a clean test means you're ready to remove the test link and ship.

Implementation time by setup type

SetupEngineering timeComplexityNotes
Pixel only (clicks, no revenue)10 minutesLowDrop script tag, done
Pixel + Stripe webhook45–60 minutesMediumStandard Next.js patterns
Pixel + Stripe + affiliate program90–120 minutesMediumAffiliate links are tagged TrackRev links — no extra wiring

Common pitfalls in Next.js attribution setups

These five issues account for approximately 90% of attribution setup problems reported by Next.js teams using TrackRev. They are not edge cases — they are predictable steps in the setup process that go wrong in the same way every time.

These are the five issues that appear most often when teams first wire up Next.js attribution. Each has a specific cause and a specific fix.

PitfallWhat causes itHow to fix it
Stripe test mode charges appearing in attributionlivemode: false charges fire the same webhook as live chargesFilter webhook handler: only process events where data.object.livemode === true
Session ID missing from Stripe metadataCookie not read before Stripe Checkout session is createdRead _rc_sid cookie server-side at checkout API route creation, not client-side
UTM values overwritten on second visitCookie set on every page load, replacing original UTMSet cookie only if _rc_sid does not already exist in the request
Attribution missing for users who sign up on mobile and pay on desktopSingle-device cookie cannot bridge devicesUse email as identity anchor: store UTM against user record at signup, not only in cookie
Cookie blocked by Safari ITP (7-day expiry)ITP degrades third-party-set cookiesTrackRev pixel sets cookie on your own domain (first-party) — ITP's 7-day cap does not apply

If attribution is recording clicks but not revenue, start with pitfalls 2 and 3 — those are the two failure modes that produce a populated click table next to an empty conversion table. If attribution is recording neither, check pitfall 1 first: test-mode charges silently polluting the live dashboard is the most common reason for "working" attribution that shows nonsense numbers.

Beyond the five most common pitfalls, four Next.js-specific patterns also bite teams during the first week of production.

Reading the cookie on Server Components. cookies() from next/headers is request-scoped. If you read it inside a Server Component or Server Action it works; if you read it inside a static client component, it won't be there.

Forgetting data-cookie-domain. Without it, the cookie defaults to your apex without the leading dot, which means subdomains can't read it. For SaaS where checkout lives on app.yourbrand.com, this kills cross-subdomain attribution.

Loading the pixel with strategy="beforeInteractive". This puts it in the critical path and hurts Core Web Vitals. afterInteractive is correct — the pixel still fires before any meaningful navigation.

Verifying the webhook signature. Production webhook signature failures show up as silent missing conversions. Use stripe.webhooks.constructEvent with the right secret, not a manual hash.

How to triage broken attribution by symptom

When attribution numbers look wrong in production, the right diagnostic path depends on the symptom. Clicks but no revenue almost always points to a missing or empty metadata.trackrev_vid on the Checkout Session — fix the cookie read in the checkout route first. Phantom $0.50 conversions are test-mode events leaking through; add the livemode filter to the webhook handler. Conversions duplicated against the same channel mean the handler is not idempotent on event.id; add the dedup table. Direct traffic suddenly explodes usually means the cookie domain is wrong and subdomains are starting fresh sessions; check data-cookie-domain against your actual app subdomain layout.

Watch out

Stripe metadata is immutable on past Checkout Sessions. If you ship the pixel without the metadata.trackrev_vid wiring, every charge created before the fix lands is permanently unattributed — you cannot backfill it from the Stripe API. Wire metadata in before the pixel sees production traffic, or connect Stripe to your app so TrackRev handles the wiring from day one.

TrackRev and Next.js

TrackRev itself runs on Next.js — the pixel, the dashboard, the webhook handlers. The Next.js integration above is the same flow we use internally. Attribution dashboard, tracking links, and affiliate program all bind to the same vid cookie.

Related reading: how to attribute Stripe revenue to marketing channels covers the conceptual model; first-party link tracking after iOS 17 explains why the cookie-domain detail matters; server-side vs client-side tracking covers where to fire the conversion event. TrackRev's free tier is enough to fully test the flow.

External references: Next.js Script component documentation; Stripe Checkout Sessions and webhooks; Vercel deployment documentation.

Frequently asked questions

How do I add revenue attribution to a Next.js SaaS app?
Add the TrackRev pixel once in app/layout.tsx so it loads on every page and sets a first-party vid cookie, read that cookie server-side at signup to bind the visitor to the user record, attach the visitor ID to your Stripe Checkout Session as metadata, then handle the checkout.session.completed webhook to forward the conversion to TrackRev. The whole loop takes about an hour with the App Router, and it ties every Stripe charge back to the channel that drove the click.
How do I capture UTM parameters in Next.js?
The TrackRev pixel, loaded in your root layout with strategy="afterInteractive", reads UTM values from the URL on landing and stores them against a session ID in a first-party cookie set on your own domain. Because the cookie is first-party, Safari's Intelligent Tracking Prevention 7-day cap does not apply to it. Set data-cookie-domain to your apex with a leading dot (for example .yourbrand.com) so subdomains like app.yourbrand.com can read the same cookie.
How do I pass a session ID through Stripe Checkout in Next.js?
In your checkout API route, read the stored visitor ID off the user record and attach it to stripe.checkout.sessions.create as a metadata field, for example metadata: { trackrev_vid: user.trackrevVid }. Stripe carries that metadata unchanged through checkout.session.completed and every subscription renewal event, so your webhook handler can read it back and credit the original channel for the lifetime of the customer. Read the cookie server-side at checkout-route time rather than client-side, where it may already be gone.
Do I need a developer to set up Next.js revenue attribution?
For the pixel-only setup (clicks, no revenue) you just drop one script tag into layout.tsx, which takes about ten minutes and needs no real engineering. Wiring the Stripe webhook for revenue attribution is roughly a 45-to-60-minute job that does require a developer comfortable with Next.js API routes and Stripe webhooks. TrackRev's Stripe integration can also pick up the conversion automatically once you connect a restricted key, which removes most of the custom webhook code.

Related articles

Stop guessing where your revenue comes from.

Set up TrackRev in 5 minutes. Free tier covers 1,000 events / month.