Documentation
Add Kicklace to a Next.js app
The one page to copy from, in the order to do it.
This is the one page to copy from: everything a Next.js app needs to send Kicklace the facts that build a customer record (who signed up, what they did, what they paid), in the order to do it. Fifteen minutes, most of it optional.
1. Install
Already have a Kicklace workspace and a setup token from its setup screen?
npx kicklace init --setup <token>
One command: it adds the tag to your root layout, adds kicklace to package.json, exchanges the
token for your two keys and writes them into .env.local, and writes a short Kicklace section into
this project's own AGENTS.md so a coding agent working here later already knows the wiring is
done. Nothing it writes is printed back: the secret key is named, never shown.
No token yet, or setting this up before a workspace exists:
npx kicklace init # add the tag, the package, and the four env names
# or, by hand:
npm install kicklace
Run npx kicklace init --dry-run first to see every change it would make without writing anything.
It refuses with one sentence if this isn't a Next.js project or has no root layout.
2. The two keys
| Key | Shape | Belongs |
|---|---|---|
| Public key | wk_… |
in the tag, in a page's source; it's public on purpose, and can only add people |
| Secret key | sk_live_… |
on your server, in an environment variable, never in browser code |
Both are made under Connections → Your website → Developers, and the same screen has the tag, a heartbeat of what has arrived, and the outgoing delivery log. A secret key is shown once and stored as a hash; lose it, and make another.
KICKLACE_SECRET_KEY=sk_live_…
NEXT_PUBLIC_KICKLACE_KEY=wk_…
KICKLACE_URL=https://www.kicklace.com
NEXT_PUBLIC_KICKLACE_URL=https://www.kicklace.com
3. The tag
One script element in <head>, or, in a Next.js app, one component in the root layout:
import { KicklaceTag } from "kicklace/next";
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="en">
<body>
{children}
<KicklaceTag />
</body>
</html>
);
}
It records page reads, remembers how each visitor arrived (the campaign, the referrer), and hears a
signup form marked data-kl-list="<your-list>" with no handler of yours. No cookies. npx kicklace init adds this for you; by hand it's:
<script async src="https://www.kicklace.com/api/in/YOUR_PUBLIC_KEY/track.js"></script>
With a hosted sign-in, Clerk's own pages most of all, there's no form on your site for the tag to hear. So pass the session, and the tag joins that browser to that person itself:
import { currentUser } from "@clerk/nextjs/server";
import { KicklaceTag } from "kicklace/next";
export default async function RootLayout({ children }: { children: React.ReactNode }) {
const user = await currentUser();
const email = user?.primaryEmailAddress?.emailAddress;
return (
<html lang="en">
<body>
{children}
<KicklaceTag
user={email ? { id: user.id, email, name: user.fullName ?? undefined } : undefined}
/>
</body>
</html>
);
}
user takes an id, email and name from any sign-in; Better Auth's and next-auth's session
reads work the same way. Nothing goes without an email: it's the address that joins one person
across devices, and it's sent only to your workspace's own public ingest route, over the key already
in the tag. npx kicklace init writes this for you where it can check the call (Sign-ups from Clerk
and Kinde says why); by hand it's the three data-kl-user-* attributes on the
script element itself, which the tag reads back on its own.
4. The events that move somebody
Where somebody stands on your Lifecycle is worked out from events, never set by hand. Post these four, exactly by name, from your own server code:
import { Kicklace } from "kicklace";
const kl = new Kicklace(process.env.KICKLACE_SECRET_KEY!);
await kl.track("signup", { email }); // moves them to Signed up
await kl.track("activated", { email }); // moves them to Activated
await kl.track("value", { email }); // moves them to First value
await kl.track("purchase", { email, value: 49, idempotencyKey: invoice.id }); // moves them to Paying
signup: an account was made.activated: the first real thing in your product happened. You decide what that is, like the first project made or the first integration connected.value: your product did what somebody came for. Post it every time, not only the first, because an app that never posts one has nobody reaching that stage.purchase: somebody paid, withvaluein your currency's main unit (49for $49.00) and anidempotencyKeyso a retried webhook lands once.churnedorsubscription_cancelled: a paying customer stopped. Neither moves anybody directly: Kicklace reads Churned off the money itself, the same way it reads Paying.
Any other name is yours: 2 to 40 characters of lowercase letters, digits and underscores, like
credit_added. It lands on the person's timeline, a stage or a side road on your Lifecycle map can
start from it, and it moves nobody by itself, because Kicklace can't know what a new name means.
Not on Node, or not using the package? Every one of these is POST https://www.kicklace.com/api/v1/events with the secret key as a bearer token, so any language that
can make an HTTP request can post them.
curl -X POST https://www.kicklace.com/api/v1/events \
-H "Authorization: Bearer sk_live_…" \
-H "Content-Type: application/json" \
-d '{"type":"purchase","email":"ada@example.com","value":49,"idempotency_key":"in_123"}'
5. Let Stripe and your auth provider do it for you
A payment and a sign-up often already happen somewhere Kicklace can hear directly, with no code and no deploy.
Stripe. Connections → Stripe → Connect asks for one restricted key (Write on Webhook Endpoints,
Read on Customers, Subscriptions, Invoices, PaymentIntents, Products and Prices). Kicklace adds its
own webhook in your Stripe account with it and reads your history. A paid invoice becomes the same
purchase event your own server would have posted.
Clerk. Connections → Clerk → Connect asks for your Clerk secret key, and Kicklace reads your
users with it every hour, with nothing to set up in Clerk. A new user becomes a signup, exactly
as if your own server had posted it.
Kinde. Connections → Kinde → Connect asks for one machine-to-machine application from Kinde's Management API; Kicklace adds its own webhook with it and reads your history.
Your history comes too: Stripe's, Clerk's and Kinde's are read the moment they're connected, with the same key or application. Two years of customers and payments become the same records the live ones would have written, dated the day the money moved or the sign-up happened.
6. The MCP server, for the agent doing this
Kicklace also answers as an MCP server, for setting a workspace up or asking about it rather than for tracking events:
https://www.kicklace.com/api/mcp
A member's own key is made at Connections → Claude; a read-only server key, for a machine rather than a person, at Connections → Your website → Developers. From Claude Code:
claude mcp add --transport http kicklace https://www.kicklace.com/api/mcp \
--header "Authorization: Bearer sk_live_…"
It never sends an email, never deletes a record, and never puts anyone on a list without the words they agreed to; a person in the workspace approves those. Everything else, like looking a customer up or writing a note, happens at once, in the key-holder's name.
Reads
Everything is readable directly: GET /api/v1/people, /organizations, /subscriptions,
/events, /releases, /journey and /populations, and one by id (/people/{id},
/organizations/{id}, /releases/{sha}), with the same secret key in Authorization: Bearer that
posts events. A list answers { data, next } and takes limit (up to 100) and after. The
kicklace package reads the same way (kl.people.list({ stage: "paying" }), kl.journey(),
kl.populations(), .iterate() where a list pages), and the MCP server offers the same ten as
resources beside its tools. Every address and field is on Reading your data.
What Kicklace knows once this is done
One record per person, joined across an anonymous visit, a signed-up account and a purchase, on whichever device each happened. Where they stand on your Lifecycle, worked out from the events above. Who is paying, who is on a trial, and who paid once and stopped. And, once you switch a sequence on, the next email at the right moment: a welcome when somebody signs up, a check-in when they never get started, a note when a payment fails.
Guides
Next.js send email: a welcome email when someone signs up is the first email, step by step. Next.js CRM for a SaaS: build one, use one, or skip it is for somebody who came looking for a CRM, and says where Kicklace sits beside one.