Skip to content

Next.js App Router setup

The minimal wiring to get Trackbridge running in a Next.js App Router project, using the @trackbridge/sdk/next and @trackbridge/sdk/next/server adapters.

You can use Trackbridge without these adapters — createBrowserTracker and createServerTracker work in any framework. The adapters exist to remove the boilerplate every Next.js integration ends up writing: gtag-loading order, the 'use client' provider, the page-view subscription to usePathname, and the module-level singleton for createServerTracker.

  • Next.js 13.4+ (App Router).
  • Trackbridge installed: pnpm add @trackbridge/sdk (or bun add / npm install).
  • A Google Ads conversion ID and a GA4 measurement ID. For server-side, the five GOOGLE_* and GA4_API_SECRET values from the Google Ads OAuth guide.
.env.local
# Public — sent to the browser
NEXT_PUBLIC_GOOGLE_ADS_CONVERSION_ID="AW-XXXXXXXXX"
NEXT_PUBLIC_GA4_MEASUREMENT_ID="G-XXXXXXXXXX"
NEXT_PUBLIC_GOOGLE_ADS_PURCHASE_LABEL="abcDEF1234"
# Server-only — never expose
GA4_API_SECRET=""
GOOGLE_ADS_DEVELOPER_TOKEN=""
GOOGLE_ADS_CUSTOMER_ID=""
GOOGLE_ADS_REFRESH_TOKEN=""
GOOGLE_OAUTH_CLIENT_ID=""
GOOGLE_OAUTH_CLIENT_SECRET=""
GOOGLE_ADS_PURCHASE_RESOURCE_NAME=""

The NEXT_PUBLIC_GOOGLE_ADS_PURCHASE_LABEL is your gtag conversion label — same string used for send_to: ${adsConversionId}/${label} on the browser side. The GOOGLE_ADS_PURCHASE_RESOURCE_NAME is the Ads API resource name (customers/{customerId}/conversionActions/{actionId}) — see Mapping conversion actions.

app/layout.tsx
import { TrackbridgeProvider, TrackbridgePageViews } from '@trackbridge/sdk/next';
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html>
<body>
<TrackbridgeProvider
config={{
adsConversionId: process.env.NEXT_PUBLIC_GOOGLE_ADS_CONVERSION_ID!,
ga4MeasurementId: process.env.NEXT_PUBLIC_GA4_MEASUREMENT_ID!,
consentMode: 'v2',
conversionLabels: {
purchase: process.env.NEXT_PUBLIC_GOOGLE_ADS_PURCHASE_LABEL,
},
}}
>
<TrackbridgePageViews />
{children}
</TrackbridgeProvider>
</body>
</html>
);
}

<TrackbridgeProvider> injects gtag('consent', 'default', { all-denied }) beforeInteractive and loads gtag.js afterInteractive. <TrackbridgePageViews /> fires trackPageView on every navigation.

For non-EEA deployments, pass consentDefaults={{ ad_storage: 'granted', /* … */ }} to override the all-denied default.

3. Use the tracker from any client component

Section titled “3. Use the tracker from any client component”
app/checkout/CheckoutButton.tsx
'use client';
import { useTracker } from '@trackbridge/sdk/next';
export function CheckoutButton({ cart }: { cart: Cart }) {
const tracker = useTracker();
return (
<button
onClick={async () => {
await tracker.trackBeginCheckout({
transactionId: cart.id,
value: cart.total,
currency: cart.currency,
items: cart.items,
});
router.push('/checkout');
}}
>
Checkout
</button>
);
}

useTracker throws if used outside <TrackbridgeProvider> — that’s a programming error, not a runtime issue.

For non-component code (e.g., calling the tracker from a Server Action’s resolved value), define a lib/tracker.client.ts module with createBrowserTracker and import it directly. The hook is for client-component composition.

lib/tracker.server.ts
import 'server-only';
import { defineServerTracker } from '@trackbridge/sdk/next/server';
export const getServerTracker = defineServerTracker(() => ({
ga4MeasurementId: process.env.NEXT_PUBLIC_GA4_MEASUREMENT_ID!,
ga4ApiSecret: process.env.GA4_API_SECRET!,
ads: {
developerToken: process.env.GOOGLE_ADS_DEVELOPER_TOKEN!,
customerId: process.env.GOOGLE_ADS_CUSTOMER_ID!,
refreshToken: process.env.GOOGLE_ADS_REFRESH_TOKEN!,
clientId: process.env.GOOGLE_OAUTH_CLIENT_ID!,
clientSecret: process.env.GOOGLE_OAUTH_CLIENT_SECRET!,
conversionActions: {
purchase: process.env.GOOGLE_ADS_PURCHASE_RESOURCE_NAME!,
},
},
conversionLabels: {
purchase: process.env.NEXT_PUBLIC_GOOGLE_ADS_PURCHASE_LABEL,
},
}));

defineServerTracker lazy-constructs the tracker on first use and caches it for the lifetime of the module. If construction fails, the error is cached and re-thrown on every subsequent call — fix the config and reload.

app/api/webhooks/stripe/route.ts
import { getServerTracker } from '@/lib/tracker.server';
export async function POST(req: Request) {
const event = await verifyStripeSignature(req);
if (event.type !== 'checkout.session.completed') return new Response();
const order = await db.orders.findByStripeId(event.data.object.id);
const result = await getServerTracker().trackPurchase({
transactionId: order.id,
value: order.total,
currency: order.currency,
items: order.items,
clientId: order.gaClientId, // forwarded from tracker.getClientId() at checkout
gclid: order.gclid,
userData: { email: order.customer.email },
});
if (!result.ads.ok && !('skipped' in result.ads)) reportError(result.ads.error);
if (!result.ga4.ok && !('skipped' in result.ga4)) reportError(result.ga4.error);
return new Response();
}

6. Optional — read identity from request cookies

Section titled “6. Optional — read identity from request cookies”

For routes where the user’s browser is making the request itself (rather than a third-party webhook like Stripe), you can pull the auto-captured _tb_* and _ga cookies straight off the request via readEnvelopeFromRequest:

app/api/orders/route.ts
import { cookies, headers } from 'next/headers';
import { readEnvelopeFromRequest } from '@trackbridge/sdk/next/server';
import { getServerTracker } from '@/lib/tracker.server';
export async function POST(req: Request) {
const order = await createOrder(await req.json());
const envelope = readEnvelopeFromRequest({
cookies: cookies(),
headers: headers(),
});
if (envelope !== null) {
const bound = getServerTracker().fromContext(envelope);
const result = await bound.trackPurchase({
transactionId: order.id,
value: order.total,
currency: order.currency,
items: order.items,
// clientId / gclid / userData all default from the envelope
});
if (!result.ads.ok && !('skipped' in result.ads)) reportError(result.ads.error);
}
return Response.json({ order });
}

For pre-login userData (or cross-origin cases where cookies are blocked), send an x-trackbridge-context header carrying tracker.exportContext({ userData }) from the browser. readEnvelopeFromRequest merges header over cookies per field.

  1. next dev and load the app. DevTools → Network: confirm gtag/js?id=AW-... loads, the consent default snippet ran, and a page_view collect request fires on navigation.
  2. Click through to checkout. Confirm begin_checkout fires (DevTools → Network → collect?...&en=begin_checkout).
  3. Trigger a test purchase via the Stripe CLI (stripe listen --forward-to localhost:3000/api/webhooks/stripe) and place an order in test mode. Confirm the webhook handler logs { ads: { ok: true }, ga4: { ok: true } } (or ads: { skipped: 'no_label_configured' } if you haven’t set conversionLabels.purchase yet).
  4. Wait up to 24 hours, then check the conversion in the Ads UI — it should appear once, deduped on transactionId.