> ## Documentation Index
> Fetch the complete documentation index at: https://docs.sagepilot.ai/llms.txt
> Use this file to discover all available pages before exploring further.

# Next.js Web Push example

> Wire authenticated Sagepilot Web Push into a Next.js App Router application from login through logout.

This example adds Sagepilot Web Push to a Next.js App Router application that already uses Auth.js. It covers the authenticated proof route, automatic connection after login, an explicit marketing-consent button, logout cleanup and retrying a failed endpoint revocation.

If your application uses another authentication library, replace only the `auth()` and `signOut()` calls. Keep the same server-owned customer identity and Push lifecycle.

<Warning>
  This example requires `@sagepilot-ai/web-push-addon` version `0.1.1` or newer. Complete the [Push channel configuration](/sdks/web/push-notifications#1-configure-the-sagepilot-channel) before running it.
</Warning>

## Files you will add

```text theme={null}
scripts/copy-sagepilot-push-worker.mjs
src/
  app/
    api/sagepilot/push-proof/route.ts
    layout.tsx
    page.tsx
  components/
    sagepilot-push-controls.tsx
    sign-out-button.tsx
  lib/
    sagepilot-push-client.ts
    sagepilot-push-proof.ts
  types/
    next-auth.d.ts
```

## 1. Install the dependencies and worker

```bash theme={null}
npm install @sagepilot-ai/web-push-addon@^0.1.1 jose
mkdir -p public/sagepilot-push
cp node_modules/@sagepilot-ai/web-push-addon/sagepilot-push-worker.js \
  public/sagepilot-push/sagepilot-push-worker.js
```

Use the portable copy script from [Deploy the service worker](/sdks/web/push-notifications#deploy-the-service-worker), and run it after install and before each build:

```json theme={null}
{
  "scripts": {
    "copy:sagepilot-push-worker": "node scripts/copy-sagepilot-push-worker.mjs",
    "postinstall": "npm run copy:sagepilot-push-worker",
    "prebuild": "npm run copy:sagepilot-push-worker"
  }
}
```

## 2. Configure the environment

Add the following values to `.env.local` and to your deployment environment:

```dotenv theme={null}
# Browser-visible workspace ID and Push channel ID.
NEXT_PUBLIC_SAGEPILOT_PUSH_KEY=workspace_id:push_channel_id

# Server-only values. Never use a NEXT_PUBLIC_ prefix.
SAGEPILOT_PUSH_CHANNEL_ID=push_channel_id
SAGEPILOT_PUSH_IDENTITY_SECRET=copy_the_generated_identity_secret
```

Restart the Next.js development server after changing these values.

## 3. Sign the authenticated customer proof

Create `src/lib/sagepilot-push-proof.ts`:

```ts theme={null}
import { SignJWT } from "jose";

type SagepilotPushCustomer = {
  id: string;
};

/** Creates a five-minute Push identity proof for one authenticated customer. */
export async function createSagepilotPushProof(
  customer: SagepilotPushCustomer,
): Promise<string> {
  const channelId = process.env.SAGEPILOT_PUSH_CHANNEL_ID;
  const identitySecret = process.env.SAGEPILOT_PUSH_IDENTITY_SECRET;
  if (!channelId || !identitySecret) {
    throw new Error("Sagepilot Push identity is not configured");
  }

  const now = Math.floor(Date.now() / 1000);
  return new SignJWT({
    channel_id: channelId,
    client_customer_id: customer.id,
  })
    .setProtectedHeader({ alg: "HS256", typ: "JWT" })
    .setAudience("sagepilot-brand-push")
    .setIssuedAt(now)
    .setExpirationTime(now + 300)
    .sign(new TextEncoder().encode(identitySecret));
}
```

The value in `session.user.id` must be your stable customer ID. Do not use a request-body customer ID, a browser-generated UUID or an ID that changes between logins.

## 4. Expose the proof through the logged-in session

Create `src/app/api/sagepilot/push-proof/route.ts`:

```ts theme={null}
import { auth } from "@/auth";
import { createSagepilotPushProof } from "@/lib/sagepilot-push-proof";

/** Returns a no-store Push proof for the current Auth.js customer. */
export async function POST(): Promise<Response> {
  const session = await auth();
  const customerId = session?.user?.id;
  if (!customerId) {
    return Response.json({ error: "Unauthorized" }, { status: 401 });
  }

  const identityToken = await createSagepilotPushProof({ id: customerId });
  return Response.json(
    { identityToken },
    { headers: { "Cache-Control": "private, no-store, max-age=0" } },
  );
}
```

Your Auth.js session callback must expose the canonical customer ID as `session.user.id`. If your auth library uses another field, map it on the server before calling `createSagepilotPushProof()`.

<Accordion title="Expose a typed customer ID from Auth.js">
  Add `src/types/next-auth.d.ts` when your application has not already extended the Auth.js session:

  ```ts theme={null}
  import type { DefaultSession } from "next-auth";

  declare module "next-auth" {
    interface Session {
      user: DefaultSession["user"] & { id: string };
    }
  }
  ```

  Then preserve your existing authentication configuration and map its stable account ID in the Auth.js session callback. For JWT sessions, `token.sub` is normally the authenticated user's stable ID:

  ```ts theme={null}
  callbacks: {
    /** Adds the authenticated account ID to the server-owned session. */
    session({ session, token }) {
      if (!token.sub) throw new Error("Authenticated customer ID is missing");
      session.user.id = token.sub;
      return session;
    },
  },
  ```

  If you use an Auth.js database adapter, map your adapter's stable `user.id` instead. Do not replace it with an email address or a browser-provided value.
</Accordion>

## 5. Own the browser lifecycle in one module

Create `src/lib/sagepilot-push-client.ts`:

```ts theme={null}
"use client";

import {
  isWebPushSupported,
  SagepilotWebPush,
  type SagepilotWebPushRegistrationResult,
} from "@sagepilot-ai/web-push-addon";

const pushKey = process.env.NEXT_PUBLIC_SAGEPILOT_PUSH_KEY;
const pendingRevocationKey = pushKey
  ? `sagepilot:web-push:revocation-pending:${pushKey}`
  : "";

let push: SagepilotWebPush | null = null;
let connection: Promise<SagepilotWebPushRegistrationResult> | null = null;

/** Returns the configured public Push key or fails before an SDK request. */
function requirePushKey(): string {
  if (!pushKey) throw new Error("NEXT_PUBLIC_SAGEPILOT_PUSH_KEY is missing");
  return pushKey;
}

/** Creates one consistently configured browser client. */
function createPush(): SagepilotWebPush {
  return new SagepilotWebPush({
    key: requirePushKey(),
    onError: (error) => console.error("[Sagepilot Web Push]", error),
  });
}

/** Removes the local subscription when the server cannot revoke it yet. */
async function removeLocalSubscription(): Promise<void> {
  const registration = await navigator.serviceWorker.getRegistration(
    "/sagepilot-push/",
  );
  const expectedScope = new URL(
    "/sagepilot-push/",
    window.location.origin,
  ).href;
  if (registration?.scope !== expectedScope) return;

  const subscription = await registration.pushManager.getSubscription();
  if (subscription && !(await subscription.unsubscribe())) {
    throw new Error("Browser refused to remove the Web Push subscription");
  }
}

/** Completes a revocation that failed during an earlier application logout. */
async function retryPendingRevocation(): Promise<void> {
  if (
    !pendingRevocationKey ||
    localStorage.getItem(pendingRevocationKey) !== "true"
  ) {
    return;
  }

  const cleanupPush = createPush();
  try {
    await cleanupPush.logout();
    localStorage.removeItem(pendingRevocationKey);
  } finally {
    cleanupPush.destroy();
  }
}

/** Identifies and synchronizes the customer already authenticated by Next.js. */
async function connectInternal(): Promise<SagepilotWebPushRegistrationResult> {
  if (!isWebPushSupported()) {
    throw new Error("Web Push is unavailable in this browser or context");
  }

  await retryPendingRevocation();
  push ??= createPush();

  const response = await fetch("/api/sagepilot/push-proof", {
    method: "POST",
    credentials: "same-origin",
    headers: { Accept: "application/json" },
  });
  if (!response.ok) throw new Error("Unable to authorize Web Push");

  const body = (await response.json()) as { identityToken: string };
  await push.identify(body.identityToken);
  return push.sync();
}

/** Connects once when React renders the signed-in application more than once. */
export async function connectSagepilotPush(): Promise<SagepilotWebPushRegistrationResult> {
  if (connection) return connection;

  connection = connectInternal();
  try {
    return await connection;
  } finally {
    connection = null;
  }
}

/** Requests permission and records the customer's disclosed marketing consent. */
export async function enableSagepilotMarketingPush(): Promise<SagepilotWebPushRegistrationResult> {
  if (!push) await connectSagepilotPush();
  if (!push) throw new Error("Sagepilot Web Push is not connected");
  return push.subscribe({ marketingConsent: true });
}

/** Revokes this browser before the host application clears its login session. */
export async function disconnectSagepilotPush(): Promise<void> {
  const currentPush = push ?? (
    isWebPushSupported() ? createPush() : null
  );
  if (!currentPush) return;

  try {
    await currentPush.logout();
    if (pendingRevocationKey) {
      localStorage.removeItem(pendingRevocationKey);
    }
  } catch (error) {
    if (pendingRevocationKey) {
      localStorage.setItem(pendingRevocationKey, "true");
    }
    await removeLocalSubscription();
    throw error;
  } finally {
    currentPush.destroy();
    push = null;
  }
}
```

The pending flag is intentionally scoped to the Push key. The add-on keeps a delete-only binding after a failed `logout()`, allowing the next page load to finish revocation before another customer is identified.

## 6. Connect after login and show explicit consent

Create `src/components/sagepilot-push-controls.tsx`:

```tsx theme={null}
"use client";

import { useEffect, useState } from "react";

import {
  connectSagepilotPush,
  enableSagepilotMarketingPush,
} from "@/lib/sagepilot-push-client";

/** Connects the signed-in customer and renders the appropriate Push action. */
export function SagepilotPushControls() {
  const [status, setStatus] = useState("connecting");
  const [error, setError] = useState<string | null>(null);

  useEffect(function connectAfterLogin() {
    let active = true;

    /** Synchronizes Push without requesting browser permission. */
    async function synchronizePush(): Promise<void> {
      try {
        const result = await connectSagepilotPush();
        if (active) setStatus(result.status);
      } catch (cause) {
        if (!active) return;
        setStatus("error");
        setError(cause instanceof Error ? cause.message : "Push setup failed");
      }
    }

    void synchronizePush();
    return function stopStatusUpdates() {
      active = false;
    };
  }, []);

  /** Requests permission only from this customer click. */
  async function enableMarketingNotifications(): Promise<void> {
    setError(null);
    try {
      const result = await enableSagepilotMarketingPush();
      setStatus(result.status);
    } catch (cause) {
      setStatus("error");
      setError(cause instanceof Error ? cause.message : "Push setup failed");
    }
  }

  if (status === "registered") {
    return <p role="status">Marketing notifications are enabled.</p>;
  }

  if (status === "permission_denied") {
    return <p role="status">Allow notifications in your browser site settings.</p>;
  }

  if (status === "permission_required" || status === "not_subscribed") {
    return (
      <button type="button" onClick={enableMarketingNotifications}>
        Enable marketing notifications
      </button>
    );
  }

  if (error) return <p role="alert">{error}</p>;
  return <p role="status">Preparing notifications…</p>;
}
```

Mount the controls from `src/app/layout.tsx` so every same-origin notification destination initializes the add-on and can flush `destination_opened`:

```tsx theme={null}
import type { ReactNode } from "react";

import { auth } from "@/auth";
import { SagepilotPushControls } from "@/components/sagepilot-push-controls";

/** Renders the application and activates Push only for a signed-in customer. */
export default async function RootLayout({
  children,
}: Readonly<{ children: ReactNode }>) {
  const session = await auth();

  return (
    <html lang="en">
      <body>
        {children}
        {session?.user?.id ? <SagepilotPushControls /> : null}
      </body>
    </html>
  );
}
```

`connectSagepilotPush()` runs after login or a signed-in page reload. It never opens the browser permission prompt. Only the visible button calls `subscribe()`.

## 7. Revoke Push before Auth.js logout

Create `src/components/sign-out-button.tsx`:

```tsx theme={null}
"use client";

import { signOut } from "next-auth/react";

import { disconnectSagepilotPush } from "@/lib/sagepilot-push-client";

/** Revokes the browser endpoint and always completes the host logout. */
export function SignOutButton() {
  /** Handles the customer-initiated sign-out action. */
  async function handleSignOut(): Promise<void> {
    try {
      await disconnectSagepilotPush();
    } catch (error) {
      console.error("Sagepilot Push cleanup will retry", error);
    } finally {
      await signOut({ callbackUrl: "/" });
    }
  }

  return (
    <button type="button" onClick={handleSignOut}>
      Sign out
    </button>
  );
}
```

Render login and logout controls from a server page such as `src/app/page.tsx`:

```tsx theme={null}
import { auth } from "@/auth";
import { SignOutButton } from "@/components/sign-out-button";

/** Shows the login state used by the Web Push lifecycle. */
export default async function HomePage() {
  const session = await auth();

  if (!session?.user?.id) {
    return <a href="/api/auth/signin">Sign in</a>;
  }

  return (
    <main>
      <h1>Welcome</h1>
      <p>Signed in as {session.user.email ?? session.user.id}</p>
      <SignOutButton />
    </main>
  );
}
```

After Auth.js completes login, Next.js renders the authenticated layout, the browser obtains a proof from the protected route and the add-on synchronizes any existing subscription. Logout attempts Sagepilot revocation before Auth.js clears the session; a temporary failure removes the local subscription and is retried on the next application load.

## 8. Verify the full flow

1. Open `/sagepilot-push/sagepilot-push-worker.js` and confirm it returns JavaScript with status `200`.
2. Sign in and confirm the proof request returns `200`; it must return `401` when signed out.
3. Confirm the page shows **Enable marketing notifications** without opening a browser prompt automatically.
4. Click the button, allow notifications and confirm the UI changes to **Marketing notifications are enabled**.
5. Send a real Journey or Campaign to a test segment containing this customer.
6. Click the notification and confirm the same-origin destination opens.
7. Verify `received`, `opened` and `destination_opened` separately in Push analytics.
8. Sign out, send again and confirm that this browser endpoint is no longer eligible.

For status meanings and failure codes, see the [Web SDK API reference](/sdks/web/api-reference). For delivery troubleshooting, return to the [complete Web Push guide](/sdks/web/push-notifications#troubleshooting).


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.