You're shipping a Next.js SaaS. You want affiliates. You look at Rewardful — $49/month. FirstPromoter — $89/month. Impact — "contact sales." All of them to do one thing: track a ?ref= query param and attribute a Stripe payment to it.
That's it. That's the core problem. You're paying three figures a month for a cookie and a dashboard.
This guide shows you how to implement affiliate tracking yourself — the right way — and introduce a free, self-hosted alternative that handles the rest of the infrastructure you don't want to build.
The Core Logic of Affiliate Tracking in Next.js
Affiliate tracking boils down to three steps:
-
Capture the
?ref=query parameter when a visitor lands - Persist it in a cookie so it survives page navigation and checkout redirects
- Pass it to your payment processor (Stripe) at checkout time ### Capturing the Referral Param
In the App Router, you can't use useSearchParams directly in Server Components. You have two clean options:
Option A — Client Component with useSearchParams
Create a component that runs on the client and reads the URL:
// components/RefTracker.tsx
'use client';
import { useSearchParams } from 'next/navigation';
import { useEffect } from 'react';
import Cookies from 'js-cookie';
export function RefTracker() {
const searchParams = useSearchParams();
useEffect(() => {
const ref = searchParams.get('ref');
if (ref) {
Cookies.set('affiliate_ref', ref, {
expires: 30, // 30-day window
sameSite: 'lax',
secure: process.env.NODE_ENV === 'production',
});
}
}, [searchParams]);
return null; // invisible component
}
Drop this into your root layout (wrapped in <Suspense>):
// app/layout.tsx
import { Suspense } from 'react';
import { RefTracker } from '@/components/RefTracker';
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="en">
<body>
<Suspense fallback={null}>
<RefTracker />
</Suspense>
{children}
</body>
</html>
);
}
Option B — Middleware (runs on every request, zero client JS)
// middleware.ts
import { NextRequest, NextResponse } from 'next/server';
export function middleware(request: NextRequest) {
const response = NextResponse.next();
const ref = request.nextUrl.searchParams.get('ref');
if (ref && !request.cookies.has('affiliate_ref')) {
response.cookies.set('affiliate_ref', ref, {
maxAge: 60 * 60 * 24 * 30, // 30 days
httpOnly: true,
sameSite: 'lax',
secure: process.env.NODE_ENV === 'production',
});
}
return response;
}
export const config = {
matcher: ['/((?!_next|api|favicon.ico).*)'],
};
Middleware is the better default — it's server-side, runs before the page renders, and doesn't require client JS. Use the Client Component approach only if you need the ref value accessible in browser JS.
Why a Cookie, Not localStorage?
- Cookies are readable server-side — your API routes can access them during checkout
- localStorage is origin-scoped but not path-scoped — fine for SPAs, fragile in server-rendered flows
- Cookies survive full-page reloads and redirects — Stripe checkout is a full redirect
Step-by-Step Code Implementation
Step 1 — Full Ref Capture + Cookie (TypeScript)
Here's a production-ready version using the middleware approach with validation:
// middleware.ts
import { NextRequest, NextResponse } from 'next/server';
const REF_COOKIE = 'affiliate_ref';
const REF_MAX_AGE = 60 * 60 * 24 * 30; // 30 days
function isValidRef(ref: string): boolean {
// Alphanumeric + hyphens only, 3–32 chars
return /^[a-zA-Z0-9-]{3,32}$/.test(ref);
}
export function middleware(request: NextRequest) {
const response = NextResponse.next();
const ref = request.nextUrl.searchParams.get('ref');
if (ref && isValidRef(ref) && !request.cookies.has(REF_COOKIE)) {
response.cookies.set(REF_COOKIE, ref, {
maxAge: REF_MAX_AGE,
httpOnly: true,
sameSite: 'lax',
secure: process.env.NODE_ENV === 'production',
path: '/',
});
}
return response;
}
export const config = {
matcher: ['/((?!_next/static|_next/image|favicon.ico|api/).*)'],
};
Step 2 — Pass the Ref to Stripe Checkout
In your checkout API route, read the cookie and attach it to the Stripe session as metadata:
// app/api/checkout/route.ts
import { NextRequest, NextResponse } from 'next/server';
import Stripe from 'stripe';
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!, {
apiVersion: '2024-04-10',
});
export async function POST(request: NextRequest) {
const affiliateRef = request.cookies.get('affiliate_ref')?.value ?? null;
const session = await stripe.checkout.sessions.create({
mode: 'subscription',
line_items: [
{
price: process.env.STRIPE_PRICE_ID!,
quantity: 1,
},
],
success_url: `${process.env.NEXT_PUBLIC_URL}/success?session_id={CHECKOUT_SESSION_ID}`,
cancel_url: `${process.env.NEXT_PUBLIC_URL}/pricing`,
// Affiliate attribution lives here
metadata: {
affiliate_ref: affiliateRef ?? '',
},
subscription_data: affiliateRef
? {
metadata: {
affiliate_ref: affiliateRef,
},
}
: undefined,
});
return NextResponse.json({ url: session.url });
}
Key points:
-
metadataon the session is readable in your Stripe webhook -
subscription_data.metadataensures the ref persists on the subscription object for recurring commission tracking - A missing ref gets an empty string — never
undefined, which would cause a Stripe API error ### Step 3 — Read the Ref in Your Stripe Webhook
// app/api/webhooks/stripe/route.ts (abbreviated)
import Stripe from 'stripe';
export async function POST(request: Request) {
const event = stripe.webhooks.constructEvent(/* ... */);
if (event.type === 'checkout.session.completed') {
const session = event.data.object as Stripe.Checkout.Session;
const affiliateRef = session.metadata?.affiliate_ref;
if (affiliateRef) {
// Credit the affiliate — look up by ref slug, record the conversion
await creditAffiliate(affiliateRef, session.amount_total ?? 0);
}
}
}
The Infrastructure Problem: Self-Hosting vs. SaaS
Tracking the cookie is 50 lines of code. What you actually get from Rewardful for $49/month is everything else:
- Affiliate dashboard — a portal where affiliates check their clicks, conversions, and earnings
- Payout management — calculating commissions, generating invoices, sending payments
- Multi-tier commissions — percentage vs. flat rate, recurring vs. one-time
- Link management — custom slugs, campaign-level tracking Building all of that from scratch is weeks of work. You don't want to do it. You want to ship your actual product.
Enter RefearnApp
RefearnApp is a free, open-source, self-hosted affiliate tracking platform built specifically for Next.js + Stripe workflows.
What it gives you out of the box:
- Affiliate dashboard — your affiliates get a real portal with their stats
- Stripe-native integration — webhooks, metadata attribution, and payout tracking built in
- Commission engine — configurable percentage or flat-rate commissions, recurring support
- Zero monthly cost — deploy it on your own infra, keep 100% of your revenue
-
No vendor lock-in — it's your database, your data, your affiliate relationships
It's the plug-and-play backend layer that sits between your cookie tracking code (above) and your affiliates. Instead of building
creditAffiliate()yourself and wiring up a dashboard, you point your webhook at RefearnApp and it handles attribution, aggregation, and reporting.
Stack it's built for: Next.js (App Router), Stripe, PostgreSQL — the same stack you're already using.
Conclusion & Next Steps
Take Control of Your Affiliate Data
RefearnApp is fully open-source, self-hostable, and AGPL-3.0 licensed. You can deploy it completely free on your own infrastructure using Coolify or check out our direct paths:
- Developer Choice: Read Deployment Docs or ⭐ Star the Repo to run it on your own VPS via Coolify with 100% data ownership.
- Production Ready: Try Managed Cloud to get up and running instantly with a fully turnkey, zero-config deployment.
Top comments (0)