Launch
Add a Kit newsletter signup to Next.js with the v4 API
A Next.js App Router signup form that adds subscribers to a Kit form through the v4 API: server action, API key kept on the server, double opt-in, tags, and what changed from v3.
This article contains affiliate links. If you buy through them, Designyff may earn a commission at no extra cost to you.
Kit’s hosted form embed is the fastest way to collect emails. It is also a third-party script, a style you do not fully control, and a request from the visitor’s browser straight to Kit. If the signup lives in your own Next.js form, you control the markup, the validation, and what else happens on submit. The cost is one server-side call to Kit’s API.
This guide wires that call through the Kit v4 API from an App Router server action. The API key stays on the server, double opt-in still works, and tags are optional. If you are still on api.convertkit.com/v3, the last section lists what breaks.
You need a Kit account with a form. Kit lists API access on its free plan at the time of writing. Check the pricing page for your own plan.
How a v4 signup works
In v3, one request to /v3/forms/:id/subscribe created the subscriber and added them to the form. In v4 that is two requests, and the order matters. The docs for adding a subscriber to a form say the subscriber must already exist.
POST /v4/subscriberscreates the subscriber. It is an upsert: an existing email only gets its first name updated.POST /v4/forms/{form_id}/subscribersadds that email to your form. It returns201for a new form subscription and200if the email was already on the form.- Optional:
POST /v4/tags/{tag_id}/subscriberstags the subscriber by email.
Every request sends the key in an X-Kit-Api-Key header. Errors come back as { "errors": ["..."] }.
Double opt-in
Double opt-in is a setting on the Kit form, not a flag on the API call. In the form’s settings, keep Send confirmation email on and Auto-confirm new subscribers off. That is Kit’s recommended default.
The API detail that matters is the subscriber’s state. Create a subscriber defaults to active, which means confirmed. For a double opt-in flow, create new subscribers as inactive and then add them to the form. They stay unconfirmed until they click the link in the confirmation email. Kit’s help center notes that unconfirmed subscribers cannot be emailed and do not count toward your billing total.
The endpoint does not change the state of a subscriber who already exists, so a confirmed reader who signs up again stays confirmed.
Create a v4 API key
In Kit, open Settings → Developer and create a V4 API key. V3 keys do not work with v4. Add the key and the form id to .env.local:
KIT_API_KEY=kit_...
KIT_FORM_ID=1234567
# optional
KIT_TAG_ID=7654321
No NEXT_PUBLIC_ prefix. That prefix inlines a value into the browser bundle. This key can read and change your whole list, so it must only be read on the server.
Form and tag ids are numbers. You can read them from the API’s list forms and list tags endpoints.
A small Kit client
Put the API calls in one server-only module. The server-only package makes the build fail if a client component ever imports it.
npm install server-only
// lib/kit.ts
import "server-only";
const KIT_API = "https://api.kit.com/v4";
export class KitError extends Error {
constructor(public status: number, public errors: string[]) {
super(`Kit API ${status}: ${errors.join(", ")}`);
}
}
async function kitPost(path: string, body: Record<string, unknown>) {
const apiKey = process.env.KIT_API_KEY;
if (!apiKey) throw new Error("KIT_API_KEY is not set");
const res = await fetch(`${KIT_API}${path}`, {
method: "POST",
headers: {
"Content-Type": "application/json",
"X-Kit-Api-Key": apiKey,
},
body: JSON.stringify(body),
});
if (!res.ok) {
const data = await res.json().catch(() => ({}));
throw new KitError(res.status, Array.isArray(data.errors) ? data.errors : []);
}
return res.json();
}
export async function subscribeToKit(input: {
email: string;
firstName?: string;
referrer?: string;
}) {
const formId = process.env.KIT_FORM_ID;
if (!formId) throw new Error("KIT_FORM_ID is not set");
// 1. Create (or find) the subscriber. "inactive" keeps new people
// unconfirmed until they click the confirmation email.
await kitPost("/subscribers", {
email_address: input.email,
first_name: input.firstName || null,
state: "inactive",
});
// 2. Add them to the form. A double opt-in form sends the confirmation email.
await kitPost(`/forms/${formId}/subscribers`, {
email_address: input.email,
referrer: input.referrer || null,
});
// 3. Optional tag.
const tagId = process.env.KIT_TAG_ID;
if (tagId) {
await kitPost(`/tags/${tagId}/subscribers`, { email_address: input.email });
}
}
referrer is worth sending. Kit stores it and parses UTM parameters out of it, so you can see which page or campaign brought a subscriber in.
The tag call runs while the subscriber is still unconfirmed. The tag does not make them emailable. If you only want tags on confirmed subscribers, drop step 3 and add the tag with a Kit automation triggered by the form instead.
The server action
// app/newsletter/actions.ts
"use server";
import { headers } from "next/headers";
import { KitError, subscribeToKit } from "@/lib/kit";
export type SignupState = { ok: boolean; message: string } | null;
const EMAIL = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
export async function subscribe(_prev: SignupState, formData: FormData): Promise<SignupState> {
// Honeypot: real people leave this hidden field empty.
if (formData.get("company")) return { ok: true, message: "Check your inbox to confirm." };
const email = String(formData.get("email") || "").trim().toLowerCase();
const firstName = String(formData.get("firstName") || "").trim();
if (!EMAIL.test(email)) return { ok: false, message: "Enter a valid email address." };
const referrer = (await headers()).get("referer") || undefined;
try {
await subscribeToKit({ email, firstName, referrer });
return { ok: true, message: "Check your inbox to confirm your subscription." };
} catch (err) {
console.error("Kit signup failed", err instanceof KitError ? err.errors : err);
return { ok: false, message: "Something went wrong. Please try again." };
}
}
The regex is a sanity check, not validation. Kit rejects malformed addresses with a 422, and double opt-in is what proves an address is real.
The action logs the Kit error but does not show it to the visitor. Kit’s error strings are written for developers.
The form
// app/newsletter/signup-form.tsx
"use client";
import { useActionState } from "react";
import { subscribe, type SignupState } from "./actions";
export function SignupForm() {
const [state, action, pending] = useActionState<SignupState, FormData>(subscribe, null);
return (
<form action={action}>
<label htmlFor="email">Email</label>
<input id="email" name="email" type="email" required autoComplete="email" />
<label htmlFor="firstName">First name (optional)</label>
<input id="firstName" name="firstName" autoComplete="given-name" />
{/* honeypot, hidden from people and screen readers */}
<input name="company" tabIndex={-1} autoComplete="off" aria-hidden="true" style={{ display: "none" }} />
<button type="submit" disabled={pending}>
{pending ? "Subscribing…" : "Subscribe"}
</button>
{state && <p role="status">{state.message}</p>}
</form>
);
}
useActionState is the React 19 hook that App Router projects use for form state. It gives you the last return value of the action and a pending flag without any client-side fetch code. The form also submits without JavaScript, because the action is a real form action.
Tell people to look for the confirmation email. Kit’s help center points out that unconfirmed signups are often people who never saw that email, and Kit does not let you resend it.
Route handler instead of a server action
Use a route handler when something other than your React form needs to subscribe people, such as a static marketing site or a mobile app.
// app/api/newsletter/route.ts
import { NextResponse } from "next/server";
import { subscribeToKit } from "@/lib/kit";
export async function POST(request: Request) {
const { email, firstName } = await request.json().catch(() => ({}));
if (typeof email !== "string" || !email.includes("@")) {
return NextResponse.json({ error: "invalid_email" }, { status: 400 });
}
try {
await subscribeToKit({ email: email.trim().toLowerCase(), firstName });
return NextResponse.json({ ok: true });
} catch {
return NextResponse.json({ error: "subscribe_failed" }, { status: 502 });
}
}
A public endpoint invites bots. Keep the honeypot idea, rate-limit by IP at your proxy or edge, and keep double opt-in on. Kit’s own form embed can use reCAPTCHA, but that protects Kit’s hosted form, not your endpoint.
Limits and failure modes
- Rate limit. API keys get at most 120 requests per rolling 60 seconds (authentication docs). A signup uses two or three requests, so a launch-day spike can hit the limit. Kit answers with
429; back off and retry, and for big imports use a CSV import or the OAuth bulk endpoints instead. - Custom fields. If you send
fields, create those custom fields in Kit first. The create-subscriber docs warn that unknown keys are rejected or ignored. - Order. Calling the form endpoint before the subscriber exists fails. Keep the create call first.
- Kit is down. The action returns an error and the email is lost. If every signup matters, also store the email in your own database first and retry the Kit call later. The referral waitlist guide shows a Prisma signup table you can reuse for that.
Migrating from the v3 API
From Kit’s upgrade guide:
- Base URL:
api.convertkit.com/v3/...becomesapi.kit.com/v4/.... - Keys: v4 API keys are new and are not compatible with v3. Authenticate with the
X-Kit-Api-Keyheader instead ofapi_keyorapi_secretin the request. - Parameters:
emailbecomesemail_addresson the form, sequence, and tag endpoints. - Form signups: the subscriber must exist before you add them to a form, which is the two-step flow above.
- Tags:
/v3/tags/:id/subscribebecomesPOST /v4/tags/:tag_id/subscribers, and the optional v3 parameters on that call are gone. - Pagination is cursor-based, and errors always use the
{ "errors": [...] }shape.
Kit marks v3 as deprecated and says it will be sunset, so new code should start on v4.
Where this fits
A newsletter form collects people who want to hear from you. A waitlist with referral codes collects people who want access and tracks who invited whom. They work together: keep the waitlist in your database, and send each signup to a Kit form with the code above so the launch email does not depend on a manual CSV export.
The Waitlist / Launch Kit ships the referral waitlist and the CSV export. It does not call Kit. The client in this guide is the piece you would add.