Next.js
A worked integration for a Next.js storefront on the App Router, from the script tag to the first events.
A complete integration for a Next.js storefront using the App Router. Every snippet is a file you can copy.
The SDK is a browser script, so every call has to happen in a client component. Server components render on the server, where window.trackbee does not exist.
| File | What it does |
|---|---|
app/layout.tsx | Loads the SDK |
types/trackbee.d.ts | Types window.trackbee |
components/trackbee.tsx | Passes consent, attaches the cart token, sends a page view per route |
app/products/[handle]/track-product-view.tsx | product_view |
components/add-to-cart-button.tsx | add_to_cart |
1. Load the SDK
Put your public API key in .env.local. It is meant to be public, so NEXT_PUBLIC_ is correct here.
NEXT_PUBLIC_TRACKBEE_PAK=pk_live_your_key_hereimport Script from 'next/script';
import { Suspense } from 'react';
import { TrackBee } from '@/components/trackbee';
const TRACKBEE_STUB = `
window.trackbee = window.trackbee || (function () {
var q = [];
function stub(method) {
return function () { q.push([method].concat([].slice.call(arguments))); };
}
return { q: q, track: stub('track'), consent: stub('consent'), cartToken: stub('cartToken') };
})();`;
export const metadata = {
title: 'Northbound',
};
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="en">
<body>
<Script
id="trackbee-stub"
strategy="beforeInteractive"
dangerouslySetInnerHTML={{ __html: TRACKBEE_STUB }}
/>
<Script
src="https://cdn.trackbee.ai/g/trackbee-tracking-sdk.min.js"
strategy="beforeInteractive"
data-pak={process.env.NEXT_PUBLIC_TRACKBEE_PAK}
data-identity-mode="internal"
/>
<Suspense fallback={null}>
<TrackBee />
</Suspense>
{children}
</body>
</html>
);
}strategy="beforeInteractive" puts both scripts in <head> and keeps the stub ahead of the SDK. It only works in the root layout.
The stub has to stay inline. Moving it to a file would make it a second network request, which defeats the point of having it.
The title ordering note does not apply here
Installation asks you to put the snippet below <title>, because an event sent before the title is parsed records an empty one. On the App Router, Next controls where <title> goes and you cannot order the snippet against it.
That is fine, because every call below happens in a useEffect, which runs after the document is parsed. Only code that calls trackbee during head parsing could hit it, and there is none here.
2. Type the global
export {};
declare global {
interface Window {
trackbee: {
track: (event: string, data?: Record<string, unknown>) => void;
consent: (options: { userData?: boolean; personalization?: boolean }) => void;
cartToken: (token: string) => void;
};
}
}Always call through window.trackbee. Never copy it into a variable: the SDK replaces the global when it starts, and a saved reference to the stub silently stops working. See the warning on Installation.
3. Consent, page views and the cart token
The SDK sends nothing on its own and does not watch for route changes, so you send one page view per route yourself. Pass consent first, so the page view carries it.
'use client';
import { useEffect } from 'react';
import { usePathname, useSearchParams } from 'next/navigation';
import { useCart } from '@/components/cart-provider';
import { useConsent } from '@/components/consent-provider';
export function TrackBee() {
const pathname = usePathname();
const searchParams = useSearchParams();
// useConsent and useCart are NOT part of the SDK. They are placeholders for your own code:
// whatever reads the visitor's answer out of your consent banner, and whatever holds the
// current cart. Build them, or swap these two lines for however your app already exposes
// that state. The rest of this component is what matters.
//
// This example assumes consent is null until the visitor has answered, and that cart is
// null when there is no cart yet.
const consent = useConsent();
const cart = useCart();
useEffect(() => {
if (!consent) return;
window.trackbee.consent({
userData: consent.analytics,
personalization: consent.marketing,
});
}, [consent]);
useEffect(() => {
if (cart?.token) window.trackbee.cartToken(cart.token);
}, [cart?.token]);
useEffect(() => {
if (!consent) return;
window.trackbee.track('page_view');
}, [consent, pathname, searchParams]);
return null;
}The three effects live in one component on purpose. Effects run in the order they are written, so consent is set and the cart token is attached before the page view goes out. Splitting them across sibling components would leave that ordering to the tree, which is easy to break later.
The if (!consent) return guard holds the consent call and the page view back until the banner has resolved. Drop it only if you have decided to track without waiting.
The cart token effect has no consent guard, because cartToken() sends nothing by itself. It only records the token for later events to carry, and those events are guarded.
Why the cart token is set here
The token is not remembered across page loads. A full document load starts clean, so setting it only when a cart is created would lose it on the next page. Setting it from the cart state means every page that has a cart attaches it, including a visitor returning to a cart from a previous session.
cart?.token in the deps means this re-runs when the cart is first loaded and if the token ever changes, and does nothing on routes where there is no cart.
Both hooks need the Suspense boundary from step 1
useSearchParams() always needs one, because search params are only known at request time. usePathname() needs one too once cacheComponents is enabled, on any route with a dynamic param that generateStaticParams does not cover. app/products/[handle] is exactly that case, and this component sits in the root layout, so it is in the tree of every such route.
Next prerenders the client component tree only as far as the nearest <Suspense>. Without the boundary from step 1, every route in the app renders on the client instead, and with cacheComponents enabled the build fails outright rather than degrading quietly.
Two things to know about the deps:
searchParamsis in there, so/products?color=olivecounts as a new page view. That is usually what you want on a storefront. If you would rather only count path changes, drop it from the array and read it withuseSearchParams()elsewhere.- React Strict Mode runs effects twice in development, so you will see two page views locally and one in production. Check against a production build before chasing it.
4. Product views
The page stays a server component. A small client component next to it sends the event.
import { getProduct } from '@/lib/shop';
import { TrackProductView } from './track-product-view';
export default async function ProductPage({ params }: { params: Promise<{ handle: string }> }) {
const { handle } = await params;
const product = await getProduct(handle); // getProduct is NOT part of the SDK. It is a placeholder for your own code.
const variant = product.variants[0];
return (
<>
<TrackProductView product={product} variant={variant} />
<h1>{product.title}</h1>
{/* ... */}
</>
);
}'use client';
import { useEffect } from 'react';
import type { Product, Variant } from '@/lib/shop';
export function TrackProductView({ product, variant }: { product: Product; variant: Variant }) {
useEffect(() => {
window.trackbee.track('product_view', {
product_id: product.id,
variant_id: variant.id,
price: variant.price,
currency: variant.currencyCode,
product_title: product.title,
variant_title: variant.title,
product_type: product.type,
product_url: `https://northbound.example/products/${product.handle}`,
image_url: variant.image?.url,
vendor: product.vendor,
sku: variant.sku,
});
}, [product, variant]);
return null;
}variant.id changing re-runs the effect, so switching variant on the same page sends another product_view. That is the behavior you want.
price must be a non-negative number or a string like '29.99'. A formatted string like '€29,99' is rejected and the event is dropped. See value rules.
5. Add to cart
'use client';
import { useTransition } from 'react';
import { addToCart } from '@/lib/shop';
import type { Product, Variant } from '@/lib/shop';
export function AddToCartButton({
product,
variant,
quantity = 1,
}: {
product: Product;
variant: Variant;
quantity?: number;
}) {
const [pending, startTransition] = useTransition();
function onClick() {
startTransition(async () => {
// addToCart is NOT part of the SDK. It is a placeholder for your own code.
await addToCart(variant.id, quantity);
// Send it once the cart actually accepted the line, not on click
window.trackbee.track('add_to_cart', {
product_id: product.id,
variant_id: variant.id,
price: variant.price,
quantity,
currency: variant.currencyCode,
product_title: product.title,
variant_title: variant.title,
product_type: product.type,
product_url: `https://northbound.example/products/${product.handle}`,
vendor: product.vendor,
sku: variant.sku,
});
});
}
return (
<button onClick={onClick} disabled={pending}>
{pending ? 'Adding...' : 'Add to cart'}
</button>
);
}Send the event after the cart call succeeds. Firing on click records adds that never happened when the request fails.
price is the unit price. Pass quantity separately and the SDK works out the line total itself.
6. The rest of the events
checkout_started, payment_info_submitted and checkout_completed follow the same two shapes you have already written.
If the event marks a page being shown, such as a checkout step or an order confirmation, use the pattern from step 4: keep the page a server component and put a small client component beside it that sends the event from a useEffect.
If the event marks something the visitor did, such as submitting payment details, use the pattern from step 5: send it from the handler, after the action has succeeded.
Each event's page lists its fields, which ones are required and what the values have to look like. A few things worth knowing before you write them:
- Guard the order event. Confirmation pages get refreshed and reopened from email, and each view would send another order. Record the order ID when you send, and skip if it comes round again.
order_idhas to be numeric, a whole number or a string of digits. A reference like'ORD-1024'is rejected and the whole event is dropped.- Give every line item its own
currency. Line items do not inherit the currency you set on the checkout or the order. - Leave
is_first_orderout when you do not know it. Defaulting it tofalsetells the ad platforms this is a returning customer, which is worse than telling them nothing.
Checking it
Run a production build, since Strict Mode doubles effects in development:
next build && next startThen walk a product page and an add to cart with the console open. You are looking for a [TrackBee] Tracking SDK line on startup and one request per action in the network tab. Troubleshooting lists every message the SDK can produce.