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_KEYis a public key -
SPM_SECRET_KEYnever appears in a"use client"file -
SPMPageviewrendered inside the provider, withautoPageviews: false -
await spm.flush()before every Route Handler / Server Action returns -
instrumentation.tsexportsonRequestError - Connector route is
runtime = "nodejs"(it needsnode:crypto)