Documentation menu

Next.js App Router

Full-stack: browser events through @saaspro/react, server events and error reporting through @saaspro/node, and the connector for user management.

npm install @saaspro/react @saaspro/node

1. Environment

# .env.local
NEXT_PUBLIC_SPM_KEY=spm_pub_prod_xxxxxxxx
SPM_SECRET_KEY=spm_sec_prod_xxxxxxxx
SPM_APP_ID=app_01HZX
SPM_CONNECTOR_PUBLIC_KEY="-----BEGIN PUBLIC KEY-----
MCowBQYDK2VwAyEA…
-----END PUBLIC KEY-----"

Only NEXT_PUBLIC_SPM_KEY reaches the browser. Never expose the secret key.

2. Provider and pageviews

// app/providers.tsx
"use client";

import { SaaSProMaxProvider } from "@saaspro/react";
import { SPMPageview } from "@saaspro/react/next";

export function Providers({ children }: { children: React.ReactNode }) {
  return (
    <SaaSProMaxProvider
      config={{
        key: process.env.NEXT_PUBLIC_SPM_KEY!,
        release: process.env.NEXT_PUBLIC_GIT_SHA,
        env: process.env.NODE_ENV,
        // <SPMPageview /> owns pageviews here, so the SDK's own
        // history patch must not send them too.
        autoPageviews: false,
      }}
    >
      <SPMPageview />
      {children}
    </SaaSProMaxProvider>
  );
}
// app/layout.tsx
import { Providers } from "./providers";

export default function RootLayout({ children }: { children: React.ReactNode }) {
  return (
    <html lang="en">
      <body>
        <Providers>{children}</Providers>
      </body>
    </html>
  );
}

SPMPageview reads usePathname and useSearchParams and wraps itself in Suspense, so it never pushes the rest of your tree into client rendering.

Set autoPageviews: false when you render <SPMPageview />. The browser SDK patches history.pushState / replaceState by default, so leaving it on gives one navigation two announcers: the patch, and the effect the component runs after the App Router has pushed. One source, chosen deliberately, is the configuration you want — and in a Next.js app it should be the component, which sees the route the router actually settled on.

The SDK also drops a $pageview for a path + search it announced less than a second ago, so an app that leaves both on is not double-counted. Treat that as the safety net it is: it protects the numbers, it does not make the second source correct. $pageleave is unaffected either way — it follows the pageview, not the setting.

3. Client-side events

// app/pricing/page.tsx
"use client";

import { TrackedButton, TrackOnView, useFlag } from "@saaspro/react";

export default function Pricing() {
  const copy = useFlag("pricing-headline", "Simple pricing");

  return (
    <TrackOnView event="pricing seen" as="section">
      <h1>{String(copy)}</h1>
      <TrackedButton event="cta clicked" properties={{ plan: "pro" }} value={49} currency="EUR">
        Start free
      </TrackedButton>
    </TrackOnView>
  );
}

4. Identify after sign-in

"use client";
import { useIdentify } from "@saaspro/react";
import { useEffect } from "react";

export function IdentifyUser({ user }: { user: { id: string; email: string; plan: string } }) {
  const identify = useIdentify();
  useEffect(() => {
    identify(user.id, { email: user.email, plan: user.plan });
  }, [identify, user.id, user.email, user.plan]);
  return null;
}

Render it from a server component that already has the session, and call spm.reset() on sign-out.

5. Server-side client

// lib/spm.ts
import { SaaSProMax } from "@saaspro/node";

export const spm = new SaaSProMax({
  secretKey: process.env.SPM_SECRET_KEY!,
  release: process.env.VERCEL_GIT_COMMIT_SHA,
  env: process.env.VERCEL_ENV ?? process.env.NODE_ENV,
});

In a Server Action or Route Handler, flush before returning — a serverless runtime can freeze the process the moment your handler resolves:

// app/api/checkout/route.ts
import { cookies } from "next/headers";
import { spm } from "@/lib/spm";

export async function POST(request: Request) {
  const order = await createOrder(await request.json());
  spm.track({
    distinctId: order.userId,
    anonymousId: (await cookies()).get("spm_aid")?.value,
    event: "order placed",
    properties: { items: order.items.length },
    value: order.total,
    currency: order.currency,
  });
  await spm.flush();
  return Response.json({ id: order.id });
}

Passing anonymousId from the spm_aid cookie is what stitches the server event onto the same visitor as the browser session.

6. Error reporting

// instrumentation.ts
import { nextRequestErrorHandler } from "@saaspro/node";
import { spm } from "./lib/spm";

export const onRequestError = nextRequestErrorHandler(spm);

export async function register() {}

Every server render and Route Handler failure is reported with its route, method and request headers (credential headers stripped).

For the client, wrap the risky parts:

"use client";
import { SPMErrorBoundary } from "@saaspro/react";

<SPMErrorBoundary tags={{ area: "checkout" }} fallback={(e, reset) => <Retry error={e} onRetry={reset} />}>
  <Checkout />
</SPMErrorBoundary>

7. Flags on the server

// app/dashboard/page.tsx
import { spm } from "@/lib/spm";
import { getSession } from "@/lib/auth";

export default async function Dashboard() {
  const session = await getSession();
  const newNav = await spm.flags.isEnabled("new-nav", { distinctId: session.userId });
  return newNav ? <NewNav /> : <OldNav />;
}

Evaluations are cached for 30 seconds per identity, so this is cheap on a hot path.

8. Connector

// app/api/spm/connector/[[...path]]/route.ts
import { createConnectorHandler, toNextRouteHandler } from "@saaspro/node";
import { handlers } from "@/lib/spm-connector";

const handler = createConnectorHandler({
  publicKey: process.env.SPM_CONNECTOR_PUBLIC_KEY!,
  audience: process.env.SPM_APP_ID!,
  handlers,
});

export const { GET, POST } = toNextRouteHandler(handler);
export const dynamic = "force-dynamic";
export const runtime = "nodejs";

See connector.md for the handler implementations.

9. Content Security Policy

If you set a CSP in next.config.ts or middleware:

connect-src 'self' https://saaspro.dev;

Checklist

  • NEXT_PUBLIC_SPM_KEY is a public key
  • SPM_SECRET_KEY never appears in a "use client" file
  • SPMPageview rendered inside the provider, with autoPageviews: false
  • await spm.flush() before every Route Handler / Server Action returns
  • instrumentation.ts exports onRequestError
  • Connector route is runtime = "nodejs" (it needs node:crypto)