Skip to content

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, with value in your currency's main unit (49 for $49.00) and an idempotencyKey so a retried webhook lands once.
  • churned or subscription_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.