DEV Community

Cover image for How to Build an Affiliate Program in Next.js (The Clean Way)
Zekariyas Berihun
Zekariyas Berihun

Posted on

How to Build an Affiliate Program in Next.js (The Clean Way)

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:

  1. Capture the ?ref= query parameter when a visitor lands
  2. Persist it in a cookie so it survives page navigation and checkout redirects
  3. 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
}
Enter fullscreen mode Exit fullscreen mode

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>
  );
}
Enter fullscreen mode Exit fullscreen mode

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).*)'],
};
Enter fullscreen mode Exit fullscreen mode

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/).*)'],
};
Enter fullscreen mode Exit fullscreen mode

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 });
}
Enter fullscreen mode Exit fullscreen mode

Key points:

  • metadata on the session is readable in your Stripe webhook
  • subscription_data.metadata ensures 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);
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

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:

Top comments (0)