How to Build an Affiliate Program in Next.js (The Clean Way)
Introduction
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 introduces 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 straightforward steps:
Step 1
Capture
The ?ref= query parameter on landing
Step 2
Persist
In a cookie to survive page navigation
Step 3
Pass
To Stripe Checkout during session creation
Capturing the Referral Param
In the Next.js App Router, you can’t use
useSearchParams directly inside Server Components.
You have two clean paths to handle this execution layer:
Option A — Client Component with useSearchParams
Create an invisible component that mounts on the client and reads the layout context:
'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,
sameSite: 'lax',
secure: process.env.NODE_ENV === 'production',
});
}
}, [searchParams]);
return null;
} Drop this component straight into your root layout context, keeping it safely wrapped in a Suspense boundary to avoid breaking server-side rendering:
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 (Zero Client JS)
Alternatively, intercept the inbound request edge directly using a Next.js middleware match pattern:
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,
sameSite: 'lax',
secure: process.env.NODE_ENV === 'production',
});
}
return response;
}
export const config = {
matcher: ['/((?!_next|api|favicon.ico).*)'],
}; Middleware is the better default choice here. It functions entirely server-side, runs before pages even execute their paint cycles, and eliminates runtime layout shift risks or client-side hydration burdens.
Why a Cookie, Not localStorage?
Server-Side Readability: Your Next.js Route Handlers and Server Actions can access incoming cookies instantly during a checkout call before rendering responses.
Path and Subdomain Scoping: Cookies can be accessed seamlessly across root domains and subdomains alike.
Resilience to Redirects: Third-party payment gateways like Stripe Checkout force external window re-routes. Cookies safely survive full-page handoffs.
Step-by-Step Code Implementation
Step 1 — Full Ref Capture + Cookie (TypeScript)
import { NextRequest, NextResponse } from 'next/server';
const REF_COOKIE = 'affiliate_ref';
const REF_MAX_AGE = 60 * 60 * 24 * 30;
function isValidRef(ref: string): boolean {
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;
} Step 2 — Pass the Ref to Stripe Checkout
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`,
metadata: {
affiliate_ref: affiliateRef ?? '',
},
});
return NextResponse.json({
url: session.url,
});
} The Infrastructure Problem: Self-Hosting vs. SaaS
Feature Block | Custom Infrastructure | SaaS Platform Strategy |
|---|---|---|
Affiliate Portal | High — Requires multi-tenant analytics UIs | $49 - $99/mo subscriptions |
Payout Engines | Complex — Generating tax ledger lines | Locked behind premium tiers |
Enter RefearnApp
Core Platform Benefits:
Native Affiliate Dashboards: Monitor conversion tracking metrics out of the box.
Full Data Sovereignty: Hosted inside your private ecosystem. No external script taxes.
Zero Monthly Fees: Scale your referral systems horizontally without operational pricing expansion.
Conclusion & Next Steps
Building an affiliate system doesn’t mean writing complex tracking matrices from scratch or paying heavy SaaS platform taxes. By managing the param edge directly via middleware, you maintain ultra-low latency execution over your conversion funnels.
Ready to Build Without Subscription Limits?
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 follow our step-by-step setup walkthrough.
Self-Hosted Open Source
Deploy directly on your own VPS via Coolify. Enjoy 100% data ownership.
Managed RefearnApp Cloud
Get up and running instantly. Skip the manual environment configuration, provisioning, and setup tasks with a fully turnkey, zero-config deployment.
Try Managed Cloud