# Integrate the Zalify pixel into a Next.js app

> Published at https://cdn.zalify.com/integrate-nextjs.md
> Self-contained guide — paste any snippet directly. Works in App Router and Pages Router. Targets Next.js 14, 15, 16+. React 18 or 19.

The Zalify pixel is a single IIFE script hosted at `https://cdn.zalify.com/pixel.js`. No npm install. Auto-tracks page views (initial + SPA navigations) and exposes `window.zalify(cmd, ...)` for custom events.

## TL;DR

Drop these two `<Script>` tags into `app/layout.tsx`:

```tsx
// app/layout.tsx
import Script from "next/script";

const WID = "your_workspace_id";

const QUEUE_STUB = `window.zalify = window.zalify || function(){(zalify.q=zalify.q||[]).push(arguments)};`;

export default function RootLayout({ children }: { children: React.ReactNode }) {
  return (
    <html lang="en">
      <body>
        <Script
          id="zalify-queue-stub"
          strategy="beforeInteractive"
          dangerouslySetInnerHTML={{ __html: QUEUE_STUB }}
        />
        <Script
          src={`https://cdn.zalify.com/pixel.js?wid=${WID}`}
          strategy="afterInteractive"
        />
        {children}
      </body>
    </html>
  );
}
```

Done. Page views fire automatically on initial load and on every SPA navigation. To fire a custom event from any client component:

```tsx
"use client";
window.zalify("track", "newsletter_signup", { kind: "footer" });
```

---

## Why two `<Script>` tags?

The first is a **queue stub** — a one-line snippet that defines `window.zalify` as a queueing function before React hydration. Without it, any `window.zalify(...)` call made during hydration (e.g. a `useEffect` on a hard-loaded route) races against the still-loading pixel and silently no-ops.

```js
window.zalify = window.zalify || function(){(zalify.q=zalify.q||[]).push(arguments)};
```

With the stub installed (`strategy="beforeInteractive"` inlines it into `<head>` before any client JS runs), every `zalify(...)` call is either dispatched immediately (if the pixel has loaded) or pushed to `window.zalify.q` and replayed when the pixel installs.

**Non-negotiable for App Router.** Without it, the first event on a hard-loaded page is dropped.

---

## App Router — full example

```tsx
// app/layout.tsx
import Script from "next/script";
import type { ReactNode } from "react";

const WID = "your_workspace_id";

const QUEUE_STUB = `window.zalify = window.zalify || function(){(zalify.q=zalify.q||[]).push(arguments)};`;

export default function RootLayout({ children }: { children: ReactNode }) {
  return (
    <html lang="en">
      <body>
        {/* 1. Queue stub — runs before hydration. */}
        <Script
          id="zalify-queue-stub"
          strategy="beforeInteractive"
          dangerouslySetInnerHTML={{ __html: QUEUE_STUB }}
        />
        {/* 2. The pixel. Loads after hydration; drains the queue on install. */}
        <Script
          src={`https://cdn.zalify.com/pixel.js?wid=${WID}`}
          strategy="afterInteractive"
        />
        {children}
      </body>
    </html>
  );
}
```

The layout stays a Server Component. `<Script>` from `next/script` handles the rest.

### Firing a typed standard event from a Client Component

Use a `useRef` to dedupe React Strict Mode's dev-only double effect invocation:

```tsx
// app/products/[id]/page.tsx
"use client";
import { use, useEffect, useRef } from "react";

export default function ProductDetailPage({
  params,
}: {
  params: Promise<{ id: string }>;
}) {
  const { id } = use(params);
  const firedFor = useRef<string | null>(null);

  useEffect(() => {
    if (firedFor.current === id) return;
    firedFor.current = id;
    window.zalify!("track", "product_viewed", {
      product: { id, title: "...", vendor: "..." },
    });
  }, [id]);

  return <div>Product: {id}</div>;
}
```

The auto pageview from the SPA listener fires separately — you don't need to call `pageview` yourself.

---

## Pages Router

Same idea, in `pages/_document.tsx`:

```tsx
// pages/_document.tsx
import { Html, Head, Main, NextScript } from "next/document";
import Script from "next/script";

const WID = "your_workspace_id";
const QUEUE_STUB = `window.zalify = window.zalify || function(){(zalify.q=zalify.q||[]).push(arguments)};`;

export default function Document() {
  return (
    <Html lang="en">
      <Head>
        <Script
          id="zalify-queue-stub"
          strategy="beforeInteractive"
          dangerouslySetInnerHTML={{ __html: QUEUE_STUB }}
        />
        <Script
          src={`https://cdn.zalify.com/pixel.js?wid=${WID}`}
          strategy="afterInteractive"
        />
      </Head>
      <body>
        <Main />
        <NextScript />
      </body>
    </Html>
  );
}
```

SPA navigations are auto-detected the same way as in App Router.

---

## API reference — `window.zalify(cmd, ...)`

| Command | Signature | Notes |
|---|---|---|
| `init` | `('init', InitOpts)` | Normally you **don't call this** — auto-fired from the script's `?wid=...` query param. Use only if you skipped the script-tag query and want to init programmatically. |
| `track` | `('track', event: string, payload?: object)` | Fire a standard or custom event. `event` may be a standard event name (`'product_viewed'`, etc.) or any custom string. |
| `track` | `('track', event, eventId: string, payload?: object)` | Same but with explicit `eventId` (useful for matching upstream IDs like Shopify `event.id`). |
| `pageview` | `('pageview', data?: object)` | Manual pageview. **Bypasses dedupe.** Only call when the auto SPA listener won't catch the navigation (rare — virtual pages, modal-as-route patterns). |
| `identify` | `('identify', traits: object)` | Attach user traits (`{ user_id, email, ... }`) to subsequent events. |
| `set` | `('set', key: string, value: any)` | Add a property to every future event. Use for environment metadata (`'app_version'`, `'experiment_bucket'`). |
| `consent` | `('consent', { analytics: 'granted' \| 'denied' })` | Only meaningful if you initialized with `consentRequired: true`. See [Consent](#consent). |

### Standard event names

These match the typed `StandardEvents` enum in the pixel:

| Event | Payload notes |
|---|---|
| `'page_viewed'` | Empty payload `{}` — pixel collects context from `window.location` |
| `'product_viewed'` | `{ product: { id, title, vendor, ... } }` |
| `'product_added_to_cart'` | `{ cartLine: {...}, cartId: string }` |
| `'collection_viewed'` | `{ collection: { id, title, productVariants: [...] } }` |
| `'search_submitted'` | `{ searchResult: {...} }` |
| `'lead'` | `{ email: string, ... }` |
| `'checkout_started'` | `{ checkout: {...} }` |
| `'payment_info_submitted'` | `{ checkout: { token, ... } }` |
| `'checkout_completed'` | `{ checkout: { token, ... } }` |

Custom events: any other string. Same call shape, payload is whatever you want.

### Script tag query params

The script `?` query supports the same fields as the `init` command:

| Query | Type | Default | Notes |
|---|---|---|---|
| `wid` | string | (required) | Workspace id. |
| `apiUrl` | string | `https://pixels.zalify.com/api/events` | Override the events endpoint (rare). |
| `configUrl` | string | `https://app.zalify.com` | Override the config base URL. |
| `debug` | `'true'` | off | Enables `[zalify]` console logs. Sticky in `localStorage` once toggled. |
| `autoPageView` | `'false'` | on | Disable the auto pageview. You'll have to call `zalify('pageview')` manually. |
| `spa` | `'false'` | on | Disable SPA listener entirely. Useful only for genuinely-static sites. |
| `consentRequired` | `'true'` | off | Defer init until `zalify('consent', { analytics: 'granted' })`. |

Example with overrides:

```tsx
<Script
  src={`https://cdn.zalify.com/pixel.js?wid=${WID}&debug=true`}
  strategy="afterInteractive"
/>
```

---

## TypeScript

Declare the global once (e.g. in `types/zalify.d.ts` — make sure it's in `tsconfig.json`'s `include`):

```ts
// types/zalify.d.ts
declare global {
  interface Window {
    zalify?: ((cmd: string, ...args: any[]) => void) & { q?: any[] };
  }
}
export {};
```

Then `window.zalify!('track', ...)` typechecks. The `!` is safe because the queue stub guarantees the global is defined before any client code runs.

If you want stricter overload typing:

```ts
declare global {
  interface Window {
    zalify?: ZalifyFn;
  }
  type ZalifyFn = {
    (cmd: 'track', event: string, payload?: Record<string, any>): void;
    (cmd: 'track', event: string, eventId: string, payload?: Record<string, any>): void;
    (cmd: 'pageview', data?: Record<string, any>): void;
    (cmd: 'identify', traits: Record<string, any>): void;
    (cmd: 'set', key: string, value: any): void;
    (cmd: 'consent', state: { analytics: 'granted' | 'denied' }): void;
    q?: IArguments[];
  };
}
```

---

## Common patterns

### Page views

You don't need to do anything. The pixel auto-fires `page_viewed` on:
1. Initial page load.
2. Every SPA navigation (Navigation API on Chromium 102+/Safari 16.4+; falls back to patching `history.pushState`/`replaceState` + `popstate` + `hashchange` elsewhere).

Dedupe is on `pathname + location.search`. Identical-path navigations don't double-fire; hash-only changes are ignored (use `zalify('pageview')` if your router treats hashes as routes).

### Product view (or any per-route side effect)

Use the `useRef` dedupe pattern from the example above. Without it, React Strict Mode (default in Next App Router dev) runs your effect twice on mount and you'll see two events.

```tsx
const firedFor = useRef<string | null>(null);
useEffect(() => {
  if (firedFor.current === id) return;
  firedFor.current = id;
  window.zalify!("track", "product_viewed", { product: { id } });
}, [id]);
```

### Identify after login

```tsx
"use client";
useEffect(() => {
  if (!user) return;
  window.zalify!("identify", { user_id: user.id, email: user.email });
}, [user?.id]);
```

### Consent

If your jurisdiction requires opt-in consent, init with `consentRequired=true`:

```tsx
<Script
  src={`https://cdn.zalify.com/pixel.js?wid=${WID}&consentRequired=true`}
  strategy="afterInteractive"
/>
```

The pixel buffers events until you call `zalify('consent', { analytics: 'granted' })`. On `'denied'`, the pixel stays inert and the buffer is discarded.

```tsx
// after user clicks "Accept" in your CMP banner:
window.zalify!("consent", { analytics: "granted" });
```

---

## Verifying the integration

In dev, append `?debug=true` to the script src and open DevTools.

**On hard load** (e.g. `/products/sku-001`):

| What | Where |
|---|---|
| `[zalify] auto-init from script.src ...` | Console |
| `GET app.zalify.com/api/v1/pixel-config/<wid>` | Network |
| `POST pixels.zalify.com/api/events` (the auto pageview) | Network |
| `[zalify] new pixel track ...` | Console |

**On SPA navigation** (clicking a `<Link>`):

| What |
|---|
| One additional `POST /api/events` per unique path. |
| No new POST when re-clicking the same path (dedupe). |

**Custom events**:

```js
// in DevTools console
window.zalify("track", "test_event", { foo: "bar" });
```

You should see one POST to `/api/events` with the payload.

---

## Common gotchas

| Symptom | Cause | Fix |
|---|---|---|
| First event on a hard-loaded route is missing | No queue stub — `<Script strategy="afterInteractive">` runs after hydration, your `useEffect` fires before `window.zalify` exists | Add the inline queue stub from [TL;DR](#tldr) |
| Custom event fires twice on every page | React Strict Mode double-invokes effects in dev | Dedupe with `useRef` (see [Product view pattern](#product-view-or-any-per-route-side-effect)) |
| Page view fires twice on first load | Both your manual `track('page_viewed')` and the auto pageview are firing | Remove your manual call — the SPA listener handles it |
| No events fire at all | Pixel didn't load (network/CSP issue) or `wid` is invalid | Check Network tab for `pixel.js` 200; check console for `[zalify]` errors; confirm `wid` exists in the workspace config |
| Events fire but no Meta/Google forwarding | Workspace config has no triggers configured for `forward_to_meta` / `forward_to_google` | Add a trigger in the workspace config |
| Hash-only navigation doesn't fire pageview | Intentional — pixel dedupe excludes the hash | Call `window.zalify('pageview')` manually on hash changes if your router uses hashes as routes |
| TypeScript: `Property 'zalify' does not exist on type 'Window'` | Missing global declaration | See [TypeScript](#typescript) |

---

## CSP

If your site enforces a Content Security Policy, add the pixel's origins:

```
script-src   'self' cdn.zalify.com connect.facebook.net www.googletagmanager.com;
connect-src  'self' pixels.zalify.com app.zalify.com;
img-src      'self' *.facebook.com www.google-analytics.com;
```

`connect.facebook.net` and `www.googletagmanager.com` are only needed if you have triggers using `forward_to_meta` / `forward_to_google` — those auto-load the respective base scripts on first use.

The inline queue stub uses `dangerouslySetInnerHTML`, which Next inlines without `eval`. With strict CSP, you'll need a nonce on the `<Script>` tag — Next supports this via the standard nonce mechanism.

---

## Reference

- Pixel JS: https://cdn.zalify.com/pixel.js
- Index for AI tools: https://cdn.zalify.com/llms.txt
