Atune Docs
Guides

Add customer health to a Next.js app

Wire Atune into a Next.js SaaS in six steps and get account and user health, an Inbox of who needs you, and an understand() API. Clerk, NextAuth and Supabase auth.

By Christopher Krohn, Founder of Atune · Updated October 19, 2026

Christopher is a SaaS founder and startup advisor who has seen first-hand how much easy-to-use, well-connected analytics matter to the success of SaaS companies. He is building Atune to help the next generation of software builders succeed with their startups.

To add customer health to a Next.js app, run npx atune init, check that the middleware resolves every signed-in request to the customer account you bill, and confirm the first event in Atune's Event log. Steps 1–3 below are enough to start; steps 4–6 make the answers better.

What you get: account and user health scores, an Inbox listing who is stuck, who is slipping and who is ready to grow (each with the reason and a drafted message), and an understand() API your code or AI agent can query. No tracking plan and no dashboards to build.

Requirements: Node 18+, Next.js 14 or later, and server-side auth with Clerk, NextAuth or Supabase (or your own session, see step 2b). Atune is in a closed beta for software teams in the EU and EEA, so you need a workspace and an API key: apply for access first. The Build plan is free for up to 25 active customer accounts.

1. Install with one command

npx atune init# When prompted, paste your workspace API key (Settings → API keys)

init detects Next.js and your auth, installs the atune package and writes two variables to .env.local:

VariableWhat
ATUNE_API_KEYYour workspace key. The only secret. Server-side only.
ATUNE_URLYour Atune host. Defaults to https://useatune.com.

Paste the key into your terminal yourself, not into an AI chat. Never ship it to the browser or commit it.

init uses your existing src/proxy.ts, proxy.ts or root middleware.ts, or creates proxy.ts (Next.js 16). Review what it wrote before you run the app: it does not reliably chain every existing handler, so check that your auth and redirects are intact. On Next.js 14/15, see Manual setup.

2. Check the middleware

The middleware records every signed-in server request as a signal, named http.<method>.<normalized-route> and resolved to the account and user. It's fire-and-forget: a slow or down Atune never delays or breaks a request. Request bodies are never read. A fresh entry point looks like this:

// proxy.ts on Next.js 16; middleware.ts on Next.js 14/15
import { createAtuneMiddleware } from "atune";

const atune = createAtuneMiddleware();

export default function capture(request: Request) {
  return atune(request);
}

2a. Already using Clerk middleware

Call atune() inside your existing handler so both run, and pass the account from the auth your callback receives (atune 0.1.5 or later):

import { clerkMiddleware } from "@clerk/nextjs/server";
import { NextResponse } from "next/server";
import { clerkAccount, createAtuneMiddleware } from "atune";

const atune = createAtuneMiddleware();

export default clerkMiddleware(async (auth, request) => {
  await auth.protect(); // Keep your app's existing auth policy.
  return atune(request, NextResponse.next(), { account: clerkAccount(auth) });
});

Inside a proxy, Clerk's session can only be read through that auth, so without clerkAccount(auth) nothing is captured. clerkAccount uses the organization as the account, or the user when there is none. If your handler builds a custom response, pass it instead of NextResponse.next(). See Chaining with an existing middleware.

Want to see it in a whole app? atune-nextjs-example is a small, public Next.js 16 + Clerk Organizations app with capture, track(), identify(), understand() and MCP wired in.

2b. Make sure "account" means the company you bill

Supabase sessions are detected automatically from the request's cookies. With NextAuth, Atune tries to read the session; if no events arrive, pass getAccount as below. If your app has teams or organizations, the team or organization is the account; otherwise the signed-in user.

No resolved account means no event. For a B2B app, check that everyone in the same customer company resolves to the same account ID. If auto-detection doesn't match your model, return the account yourself with getAccount, from your verified server session (never from a client header):

createAtuneMiddleware({
  getAccount: async (request) => {
    const session = await getSession(request);
    if (!session) return null;
    return { accountId: session.orgId, userId: session.userId };
  },
});

Not sure what your account is? Ask what you would bill as one unit.

Supabase: Atune reads the session through @supabase/ssr (install it if your app only uses @supabase/supabase-js) and needs NEXT_PUBLIC_SUPABASE_URL plus either NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY (current Supabase projects and the Vercel/v0 integration) or the legacy NEXT_PUBLIC_SUPABASE_ANON_KEY. npx atune init warns when one is missing.

3. Verify an event landed

Sign in to your app and make one request. Then open Settings → Event log in Atune. The newest event should be named http.<method>.<route> and carry the account and user you just used.

Nothing there? Check that ATUNE_API_KEY is set, that ATUNE_URL is the bare host (no /api/v1/... path; the SDK adds it), and that the request resolves to an account. The installer's success message is not proof of delivery.

At this point Atune is capturing. Health scores and Inbox actions build up from real usage; there's nothing to configure.

Atune doesn't guess who pays. Until your app says so, every account is shown as an assumed trial, and the advice that only makes sense for paying customers (churn risk, gone quiet, champion cooling, upsell) stays off. Return a subscription from getAccount and it's sent with every captured request.

If you already keep a copy of each customer's Stripe subscription, map it with toAtuneSubscription() (atune 0.1.6 or later):

import { createAtuneMiddleware, toAtuneSubscription } from "atune";

const atune = createAtuneMiddleware({
  getAccount: async (request) => {
    const session = await getYourSession(request);
    if (!session) return null;
    const subscription = await db.subscription.findUnique({ where: { orgId: session.orgId } });
    return { accountId: session.orgId, userId: session.userId, subscription: toAtuneSubscription(subscription?.stripeObject) };
  },
});

Read your own copy; don't call Stripe on every request. Atune never calls Stripe for you, and only stage, plan, seats, MRR, currency, trial end and a cancellation reason are sent. On Clerk Billing, use clerkAccount(auth, { billing: true }) instead. With your own plan column, pass the fields directly. All three are in Billing status.

With these fields, a paying account that lowers its MRR, drops seats, moves to a cheaper plan or goes back to trial gets a Downgraded action in the Inbox, and MRR raises the priority of an account's actions. See Trial, paying and subscription value.

5. Send names and a few business events (optional)

Names. Answers read "Kari at Acme" instead of "user u_123" once you send identity. Personal data is processed only after you accept the data processing agreement (DPA) in Atune:

import { identify, group } from "atune";

await identify("u_123", { name: "Kari Nordmann", email: "kari@acme.no" }, { accountId: "acme" });
await group("acme", { company: "Acme AS" });

Named events. The middleware already captures requests. For the 3–5 actions that matter most (creating a resource, completing the core action, inviting a teammate), fire a named event where your handler knows the account. Don't await it:

// app/api/reports/route.ts
import { track } from "atune";

export async function POST(req: Request) {
  const { orgId, userId } = await getYourSession(req); // your verified session
  // ...your existing handler logic, unchanged

  void track("feature.report.generated", {
    accountId: orgId,
    userId,
    properties: { reportType: "activation" },
  });

  return Response.json({ ok: true });
}

track() never throws and gives up after one second. See Named events.

6. Ask about an account from code

Customer health shows up in the Atune app (Inbox, Accounts, Users). You can also ask from your own server code, for an internal admin page or a script:

import { understand } from "atune";

const r = await understand("account:acme", "how healthy is this account?");

if (r.knows) {
  console.log(r.answer);          // plain language, grounded in your signals
  console.log(r.confidence);      // 0..1
  console.log(r.citations);       // the facts behind the answer
  console.log(r.recommendedNext); // one action, only when confident
} else {
  // Not enough understanding yet. Don't force an answer.
}

Entities can be account:<id>, user:<accountId>/<userId>, feature:<key> or portfolio (your whole customer base). Always branch on knows. Keep understand() on the server: it uses ATUNE_API_KEY, and it throws when the key is missing or Atune returns an error, so wrap it in try/catch where a failure must not break the caller. If you expose it through a route, protect that route like any other admin endpoint. The free Build plan includes 20 new answers per UTC day; a question repeated within the hour is served from cache.

A knows: false on day one usually means there's not enough history yet. It doesn't tell you on its own whether capture failed; check the Event log.

What this does and doesn't cover

  • Covered: server-side capture for signed-in requests, account resolution from Clerk, NextAuth and Supabase, subscription and downgrade signals, identity under a DPA, cited answers.
  • Not covered: anonymous marketing-site analytics, session replay and client-side events. Atune is server-side and focused on customers, not traffic. If you also want product analytics charts, keep PostHog; Atune can import from PostHog instead of installing anything.
  • Not on Next.js? Use track() from any Node backend or the HTTP API from any language: Any backend.

Next

On this page