Install on Next.js App Router
Use next/script in app/layout.tsx with strategy="afterInteractive". Raw script tags in metadata get stripped. Server Actions can pass the affiliate slug via cookies().
Next.js 16's App Router serializes the document differently from the Pages Router, and the metadata API doesn't accept raw <script> tags (they get stripped on serialization). The supported install is the next/script component in your root layout. App-Router-specific patterns also apply for Server Actions, Streaming SSR, and the Content Security Policy if you have one configured.
Install in app/layout.tsx
Edit app/layout.tsx and drop a <Script> component at the end of <body>:
import Script from "next/script";
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="en">
<body>
{children}
<Script
id="rekomi"
src="https://js.rekomi.com/v1/rekomi.js"
strategy="afterInteractive"
data-program-id={process.env.NEXT_PUBLIC_REKOMI_PROGRAM_ID}
/>
</body>
</html>
);
}strategy="afterInteractive" is the right choice for click capture: it loads after hydration but before the user idles, so the cookie is ready before the visitor clicks any CTAs. strategy="lazyOnload" would defer too long; strategy="beforeInteractive" would block the critical render path.
Set NEXT_PUBLIC_REKOMI_PROGRAM_ID in your .env.local (and Vercel/Netlify env vars for production). The NEXT_PUBLIC_ prefix is required for Next to expose the variable to the browser bundle.
Fire convert from a client component
Once the script loads, window.Rekomi is globally available. Fire the convert event from a client component on your post-checkout success route, typically app/success/page.tsx:
"use client";
import { useEffect } from "react";
import { useSearchParams } from "next/navigation";
export default function SuccessPage() {
const searchParams = useSearchParams();
useEffect(() => {
const sessionId = searchParams.get("session_id");
if (!sessionId) return;
// If you pass amount + email via Stripe Checkout's success URL, parse them
// here. Otherwise, fetch /api/checkout-session/[id] and read the values
// from your backend (recommended for production — URL params can be tampered).
const amount = parseInt(searchParams.get("amount") || "0", 10);
const email = searchParams.get("email") || "";
if (window.Rekomi) {
window.Rekomi.convert({
external_event_id: sessionId,
amount_cents: amount,
currency: "USD",
customer_email: email,
});
}
}, [searchParams]);
return <div>Thanks for your purchase!</div>;
}useSearchParams reads query string params from the URL. For production, fetch the canonical order data from your backend rather than trusting URL params (a buyer could tamper with ?amount=1 to fake a small refund).
Server Actions: pass the affiliate slug via cookies()
If you use Server Actions for checkout (the modern Next 16 pattern — form action={async () => "use server"; ...}), the affiliate slug is already in the visitor's cookie jar from the Rekomi loader. Read it server-side in the action and forward to Stripe's create-session call as client_reference_id:
// app/actions.ts
"use server";
import { cookies } from "next/headers";
import Stripe from "stripe";
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);
export async function createCheckoutSession(priceId: string) {
const cookieStore = await cookies();
const referral = cookieStore.get("rekomi_via")?.value;
const session = await stripe.checkout.sessions.create({
mode: "subscription",
line_items: [{ price: priceId, quantity: 1 }],
success_url: `${process.env.NEXT_PUBLIC_APP_URL}/success?session_id={CHECKOUT_SESSION_ID}`,
cancel_url: `${process.env.NEXT_PUBLIC_APP_URL}/pricing`,
client_reference_id: referral,
subscription_data: referral
? { metadata: { rekomi_affiliate_slug: referral } }
: undefined,
});
return session.url;
}This pattern is cleaner than passing the referral through an HTTP header — cookies() is the canonical Next 16 way to read request cookies server-side.
Content Security Policy
If you have a strict CSP configured in next.config.ts headers, allow Rekomi's script source and tracking endpoints:
// next.config.ts
const ContentSecurityPolicy = `
default-src 'self';
script-src 'self' https://js.rekomi.com;
connect-src 'self' https://api.rekomi.com;
img-src 'self' https://api.rekomi.com data:;
`;
export default {
async headers() {
return [{
source: "/(.*)",
headers: [{
key: "Content-Security-Policy",
value: ContentSecurityPolicy.replace(/\s{2,}/g, " ").trim(),
}],
}];
},
};Without script-src permission, the browser silently blocks the loader. Without connect-src, the click and convert POSTs fail.
Middleware: usually unnecessary
Click capture happens entirely client-side (the loader runs after hydration, sets the cookie, exposes window.Rekomi). You only need to add Rekomi logic in middleware.ts if you want server-side attribution before the page renders (e.g., to gate landing-page content by referrer). For standard SaaS flows, the client-side capture is sufficient.
Quirks worth knowing
Don't put raw <script> in metadata.other. App Router strips raw script tags from metadata for security. Use next/script in app/layout.tsx instead.
Metadata in client components is ignored. Next 16 only respects metadata exports from server components. The app/layout.tsx is server by default; keep it that way.
strategy="afterInteractive" is the right choice. Other strategies (beforeInteractive, worker, lazyOnload) have different trade-offs. After-interactive is correct for click-capture analytics because it loads after the page is usable but before idle.
Static export (next export) needs special handling. If you build with output: "export", the next/script component still works but Server Actions don't (they require a Node runtime). Use the URL-params or backend-fetch path for the convert event on statically-exported sites.
Troubleshooting
Script tag appears in HTML but loader doesn't execute. CSP blocking. Check the browser console for CSP violation errors and add js.rekomi.com to script-src.
client_reference_id is empty on the Stripe Session. Either the cookie wasn't set (the loader didn't run on the page the affiliate sent traffic to), or your Server Action isn't reading cookies() correctly. Add a console.log(referral) before the Stripe call to verify.
useSearchParams returns null in production but works locally. App Router requires useSearchParams to be wrapped in <Suspense> for static optimization. Wrap the SuccessPage's body in <Suspense fallback={null}> if you're hitting this.
Hydration mismatch error after adding <Script>. Make sure you're not putting <Script> outside <html> or <body>. The component goes inside <body> (typically at the end, so it doesn't block render).
Related
- JavaScript pixel reference
- Install on Stripe — for
client_reference_idpatterns - Install on Next.js Pages Router — for the legacy Pages Router pattern
Install on Vanilla JavaScript
Plain HTML, no framework, no bundler. Script tag in the head of every page for click capture, convert snippet on the thank-you page only.
Install on Next.js Pages Router
Use next/script in pages/_app.tsx. Don't paste raw script tags in _document.tsx, they conflict with serialization. Convert fires from useEffect in a success page using useRouter.