Next.js + Stripe subscriptions, end to end

A complete referral program in a Next.js app billed with Stripe subscriptions: capture the referral, attribute the sign-up, forward payments, refunds and disputes, receive webhooks and show the referral panel.

Embed
Preview of the card
<a href="https://referalli.com/docs/nextjs-stripe"><img src="https://referalli.com/docs/_api/public/orgs/07f432f4-34b9-46a7-aaad-c0e395d2e4f9/spaces/2bf4f614-3630-4fbf-85f9-5ecafa6b7d97/pages/80295772-6ae8-4dc3-86c1-4ee96ecf9ab5/social-card.svg?v=3e7ae33eec8b" alt="Next.js + Stripe subscriptions, end to end" width="600" height="315"></a>

This guide wires a referral program into a Next.js app (App Router) that bills with Stripe subscriptions, in five steps. Each step links to the page with the details.

You need a program (prg_…), a secret API key (rk_…), your public tracking token (tk_…) and, for step 3, a billing connection.

npm install @referalli/next @referalli/react referalli
# .env.local
REFERALLI_API_KEY=rk_live_...               # server only
REFERALLI_PROGRAM_ID=prg_...
NEXT_PUBLIC_REFERALLI_TRACKING_TOKEN=tk_...
REFERALLI_BILLING_SECRET=...                # step 3, generic connection only
REFERALLI_WEBHOOK_SECRET=whsec_...          # step 4

1. Capture the referral

A partner's link lands the visitor on your site with ?via=<code>. Save it in the first-party cookie before any page renders. On Next.js 16 that is proxy.ts; on 15 and earlier, middleware.ts with export const middleware:

// proxy.ts
import { referalliMiddleware } from '@referalli/next/middleware';
export const proxy = referalliMiddleware({ consent: 'cookiemunch' });
export const config = { matcher: ['/((?!_next|api|.*\\..*).*)'] };

Drop consent only where no opt-in is needed. If your consent banner can be accepted on the landing page, add <ReferalliProvider> to the root layout too. See React and Next.js.

2. Attribute the sign-up

Where you create the user, file the sign-up under the id Stripe will report for this customer. The simplest choice is your own user id, put on the Stripe customer as metadata.userId when you create it:

// app/signup/actions.ts
'use server';
import { Referalli } from 'referalli';
import { attributeSignup } from '@referalli/next';
import Stripe from 'stripe';

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

export async function signUp(form: FormData) {
  const user = await db.users.create({ email: String(form.get('email')) });
  await stripe.customers.create({ email: user.email, metadata: { userId: user.id } });
  await attributeSignup({ client: referalli, programId: process.env.REFERALLI_PROGRAM_ID!, customerRef: user.id, email: user.email });
}

attributeSignup never throws, so attribution can't break sign-up. If your users sign in with Atlas or another auth platform, an auth connection files the sign-up server-side and this step goes away.

3. Forward payments, refunds and disputes

Pick one of two ways, not both (they record under different ids, so a payment would earn twice).

A. Let Referalli receive Stripe's webhooks. One call with a restricted key registers the endpoint in your Stripe account; nothing in your app changes. See Connect Stripe in one call. Map customers to your user ids with the customer's metadata, as in step 2.

B. Forward the Stripe webhooks your app already receives. Your webhook route maps each Stripe event with stripeEventToBillingEvents and queues it in a durable outbox, so an outage on either side loses nothing:

// app/api/stripe/webhook/route.ts
import Stripe from 'stripe';
import { createOutbox, pgOutboxStore, stripeEventToBillingEvents } from 'referalli';

const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);
const outbox = createOutbox({
  store: pgOutboxStore({ query: (sql, params) => pool.query(sql, params) }), // or sqliteOutboxStore on SQLite / libSQL
  connectionId: process.env.REFERALLI_BILLING_CONNECTION_ID!,               // a generic billing connection
  secret: process.env.REFERALLI_BILLING_SECRET,
});

export async function POST(req: Request) {
  const event = stripe.webhooks.constructEvent(await req.text(), req.headers.get('stripe-signature')!, process.env.STRIPE_WEBHOOK_SECRET!);
  // ... your own handling ...
  const customerRef = async (cus: string) => ((await stripe.customers.retrieve(cus)) as Stripe.Customer).metadata?.userId;
  for (const e of await stripeEventToBillingEvents(event, { customerRef })) await outbox.enqueue(e);
  void outbox.flush();
  return new Response('ok');
}

Subscribe the Stripe endpoint to invoice.paid, charge.refunded and charge.dispute.created at least (plus customer.subscription.created / .deleted for trial bounties and churn). Forwarding only payments leaves refunds and disputes unreversed: the partner keeps commission on money you gave back.

On SQLite or libSQL, use sqliteOutboxStore in place of pgOutboxStore. If you'd rather not run an outbox at all, POST /v1/billing/connections/{id}/stripe-forward takes the raw Stripe event, signed with the connection secret, and Referalli maps it with the same code. See Billing providers.

Run npx referalli doctor --e2e to send a sandbox sign-up and conversion through the whole chain.

4. Receive Referalli's webhooks

React to referrals and commissions in your app, for example to grant the referred customer a perk or to show a toast to the partner:

// app/api/webhooks/referalli/route.ts
import { createWebhookHandler } from '@referalli/next';

export const POST = createWebhookHandler({
  secret: process.env.REFERALLI_WEBHOOK_SECRET!,
  on: {
    'referral.created': async (event) => grantWelcomeCredit(event.data.customerRef),
    'commission.reversed': async (event) => console.log('clawed back', event.data.amountMinor),
  },
});

Create the endpoint with POST /v1/webhook-endpoints (or the dashboard) and subscribe to the topics you handle. See Webhooks.

5. Show the referral panel

Give each signed-in user their own code and link on an account page. <ReferralPanel> is a server component: it reads the panel with your rk_ key on the server, so the page loads no widget script.

// app/account/referrals/page.tsx
import { ReferralPanel } from '@referalli/next/server';

export default async function Page() {
  const user = await currentUser(); // your session helper
  return (
    <ReferralPanel
      programId={process.env.REFERALLI_PROGRAM_ID!}
      trackingToken={process.env.NEXT_PUBLIC_REFERALLI_TRACKING_TOKEN!}
      customerRef={user?.id}
      email={user?.email}
      styled
    />
  );
}

With settings.widget.autoEnroll on the program, a user who isn't a partner yet is enrolled on first view (that's why email is passed). See Referral panel on the server.

What partners see

With the program's notification emails on (the default for new programs), the partner gets an email when someone signs up through their link and another when that customer first pays, then one per later commission and approval. See Emails.

Updated