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:
| Variable | What |
|---|---|
ATUNE_API_KEY | Your workspace key. The only secret. Server-side only. |
ATUNE_URL | Your 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.
4. Tell Atune who pays (recommended)
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
- A complete, working app to copy from: atune-nextjs-example (team = account, named events, identity under a DPA, MCP config).
- Let your AI coding agent query the same understanding over MCP: Give your coding agent customer understanding.
- Full reference: SDK, understand().