Next.js recipe
Production-grade ReTarget.gg install for Next.js App Router: env-scoped keys, CSP-friendly script loading, TypeScript-safe Trigger Widget flow.
Production install for Next.js 14/15 App Router. Covers env-scoped keys, CSP headers, and a typed helper for the Trigger Widget flow.
Pages Router?
The patterns translate 1:1. Swap app/layout.tsx for pages/_app.tsx and wrap your component tree with the same <Script> placement.
1. Environment variables
Put keys in .env.local (or your Vercel project env):
# Public: safe in the browser (per-website keys)
NEXT_PUBLIC_RETARGET_PUB=pk_your_public_key
NEXT_PUBLIC_RETARGET_WEBSITE=web_your_website_key
# Server only: Trigger Widget flow
RETARGET_INTEGRATION_SECRET=its_your_integration_secret
RETARGET_API_URL=https://api.retarget.ggVercel users: set these under Project Settings → Environment Variables with the right env (production / preview / development).
2. Install widget.js in app/layout.tsx
// app/layout.tsx
import Script from "next/script";
export default function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<html lang="en">
<body>
{children}
<Script
id="retarget"
src="https://cdn.retarget.gg/widget.js"
data-pub={process.env.NEXT_PUBLIC_RETARGET_PUB}
data-website={process.env.NEXT_PUBLIC_RETARGET_WEBSITE}
strategy="afterInteractive"
/>
</body>
</html>
);
}strategy="afterInteractive" loads the script after hydration: it never blocks first paint. Next.js server-renders the tag with the env values baked in, so no client-side round trip.
3. Section widget on a specific page
// app/blog/[slug]/page.tsx
import Script from "next/script";
export default function BlogPost() {
return (
<article className="prose">
{/* ...your content... */}
<div id="retarget-ads" className="my-10" />
<Script
id="retarget-section"
src="https://cdn.retarget.gg/widget-section.js"
data-pub={process.env.NEXT_PUBLIC_RETARGET_PUB}
data-website={process.env.NEXT_PUBLIC_RETARGET_WEBSITE}
data-container="retarget-ads"
strategy="afterInteractive"
/>
</article>
);
}4. CSP-friendly setup
If you use a Content Security Policy, allow ReTarget.gg's origins in your next.config.ts or middleware:
// next.config.ts
const cspHeader = `
default-src 'self';
script-src 'self' 'unsafe-inline' https://retarget.gg;
connect-src 'self' https://api.retarget.gg https://retarget.gg;
frame-src https://retarget.gg;
img-src 'self' data: https: blob:;
style-src 'self' 'unsafe-inline';
`.replace(/\s{2,}/g, " ").trim();
const nextConfig = {
async headers() {
return [
{
source: "/(.*)",
headers: [{ key: "Content-Security-Policy", value: cspHeader }],
},
];
},
};
export default nextConfig;Strict CSP ('nonce-…')
If you use nonce-based CSP, pass the nonce to <Script nonce={nonce} /> so Next.js forwards it to the injected <script>. Otherwise the browser rejects it.
5. TypeScript for the Trigger Widget session event
The Trigger Widget is the only surface that dispatches a DOM event: retarget:decline-session. Augment the window type so your event handlers are typed:
// types/retarget.d.ts
interface RetargetDeclineSessionDetail {
sessionId: string;
}
declare global {
interface WindowEventMap {
"retarget:decline-session": CustomEvent<RetargetDeclineSessionDetail>;
}
interface Window {
__RETARGET_DECLINE_SESSION_ID?: string;
}
}
export {};No events from geo-blocking / section widgets
The geo-blocking (widget.js) and section (widget-section.js) widgets do not emit DOM events today. If you need to forward decision/impression/click activity into your own analytics, use the Scheduled CSV exports in the dashboard (see Analytics & reporting): the CSV arrives on your cadence with the same rows the widget emits.
6. Trigger Widget flow: full example
Server route for triggering
// app/api/retarget/trigger/route.ts
import { NextResponse } from "next/server";
const API = process.env.RETARGET_API_URL ?? "https://api.retarget.gg";
export async function POST(req: Request) {
const secret = process.env.RETARGET_INTEGRATION_SECRET;
if (!secret) {
return NextResponse.json(
{ error: "RETARGET_INTEGRATION_SECRET missing" },
{ status: 500 },
);
}
const { sessionId } = (await req.json().catch(() => ({}))) as {
sessionId?: string;
};
if (!sessionId) {
return NextResponse.json({ error: "sessionId required" }, { status: 400 });
}
// Pair sessionId with your authenticated user here before triggering
// e.g. lookup the user session and persist the pairing.
const res = await fetch(`${API}/v1/widget-decline/trigger`, {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${secret}`,
},
body: JSON.stringify({ sessionId }),
});
if (!res.ok) {
return NextResponse.json(
{ error: await res.text() },
{ status: res.status },
);
}
// The API returns { ok: true } on success
const body = await res.json();
return NextResponse.json(body, { status: 200 });
}Client bridge
// app/declined/decline-bridge.tsx
"use client";
import { useEffect } from "react";
export function DeclineBridge() {
useEffect(() => {
const send = (sessionId: string) =>
fetch("/api/retarget/trigger", {
method: "POST",
credentials: "same-origin",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ sessionId }),
});
const handler = (ev: WindowEventMap["retarget:decline-session"]) => {
void send(ev.detail.sessionId);
};
window.addEventListener("retarget:decline-session", handler);
const existing = window.__RETARGET_DECLINE_SESSION_ID;
if (existing) void send(existing);
return () =>
window.removeEventListener("retarget:decline-session", handler);
}, []);
return null;
}The decline page
// app/declined/page.tsx
import Script from "next/script";
import { DeclineBridge } from "./decline-bridge";
export default function DeclinedPage() {
return (
<>
<Script
id="retarget-decline"
src="https://cdn.retarget.gg/widget-decline.js"
strategy="beforeInteractive"
data-pub={process.env.NEXT_PUBLIC_RETARGET_PUB}
data-website={process.env.NEXT_PUBLIC_RETARGET_WEBSITE}
/>
<DeclineBridge />
<main className="prose mx-auto py-20">
<h1>We can't proceed with this account</h1>
<p>You may see alternative offers while we review.</p>
</main>
</>
);
}Server-only secrets
RETARGET_INTEGRATION_SECRET must never have NEXT_PUBLIC_: if it does, Next will ship it to the browser and you'll need to rotate in the dashboard immediately.
7. Preview / staging hosts
Each ReTarget.gg website is tied to a single registered domain. For Vercel preview URLs you have two paths:
-
Treat staging as its own website. Add a second website in the dashboard with
your-project.vercel.appas the domain, use its keys on preview deploys, and keep production keys isolated. -
Point
data-apiat a staging API if you have one, so preview traffic doesn't pollute production analytics:<Script src="https://cdn.retarget.gg/widget.js" data-pub={process.env.NEXT_PUBLIC_RETARGET_PUB} data-website={process.env.NEXT_PUBLIC_RETARGET_WEBSITE} data-api={process.env.NEXT_PUBLIC_RETARGET_API_URL} strategy="afterInteractive" />
Gotchas
- Middleware redirects: If your app redirects based on geo at the edge, make sure ReTarget.gg's script tag still ends up in the final rendered HTML. A middleware that rewrites to a blank page won't run the widget.
- Partial prerendering (Next 15):
<Script>works in PPR; keep it out of suspense boundaries or it'll render twice. - Static exports (
output: "export"): Works fine. The script is fully client-side, no server runtime needed.
Related
Publisher
Geo Popup & Decline Popup overview
The product framing for the install you just read.
Advertiser
CPC, CPA, eCPM in one auction
The demand side of the network — useful context even for publishers.
Essays
Long-form strategy
How others think about geo-blocking, KYC declines, and bid models.
Need help with setup?
Send us your website stack, target regions, and whether you are installing Geo Popup or Decline Popup.