Newsletter signup form9 min read
Build a newsletter signup form in Next.js
A Next.js footer newsletter form: a server action with a consent check and Turnstile, posting to Ikiform, plus a route handler that adds readers to Kit.
Put a client form in the root layout's footer and point it at a server action. The action checks the consent box and a Cloudflare Turnstile token, then forwards the signup to your form backend with a secret key. A separate route handler receives the backend's signed webhook and adds the reader to your email tool, so spam never reaches your list.
The main guide's Next.js tab has a server action and a form component. This page builds the full thing for a real site: one form in the footer of every page, a human check that still works when the secret key skips Ikiform's, and a route handler that passes confirmed-good signups to Kit. Product setup and keys are in the Next.js docs guide.
What a footer form changes in Next.js
A footer form lives in the root layout, so it renders on every page, including pages Next.js builds once at deploy time. Three things follow from that:
| Concern | What happens | What this build does |
|---|---|---|
| Static pages | The form must not read cookies or headers while rendering, or every page turns dynamic | The form reads nothing at render. Only the action touches the request |
| The human check | A post with a secret key skips Ikiform's Turnstile check | Runs its own Turnstile check inside the action |
| Who can call it | A server action is a public POST endpoint, reachable without your form | Validates consent and the token before anything is sent |
- The reader fills in the footer form. It posts to the server action on whatever page they're reading.
- The action checks the consent box, then verifies the Turnstile token with Cloudflare.
- If either check fails, the form comes back with the message and the typed email. Nothing is sent.
- Otherwise the action posts the signup to Ikiform with the secret key and the browser's Origin.
- Ikiform's answer becomes the action state, and the form is replaced with the check-your-inbox line.
- Separately, Ikiform sends a signed webhook for every signup that isn't spam. The route handler verifies it and adds the reader to a Kit form, whose incentive email is Kit's confirmation. Test that once with an address you own; the Kit guide explains why.
Project setup
app/
layout.tsx
newsletter/
actions.ts
newsletter-form.tsx
submit-button.tsx
page.tsx
api/ikiform/route.ts
lib/
turnstile.ts
.env.localCreate a secret key under API keys with the submissions:write scope. Create a Turnstile widget in the Cloudflare dashboard for your domain and localhost. Only the site key may carry the NEXT_PUBLIC_ prefix; the other values must stay server-only.
IKIFORM_SECRET_KEY=ik_sk_your_secret_key
NEXT_PUBLIC_TURNSTILE_SITE_KEY=your_turnstile_site_key
TURNSTILE_SECRET_KEY=your_turnstile_secret_key
IKIFORM_WEBHOOK_SECRET=whsec_your_webhook_secret
KIT_API_KEY=your_kit_v4_api_key
KIT_FORM_ID=1234567Paste the pillar's field definition on the form's Fields tab, so Ikiform enforces the required consent box whatever reaches it.
Build it
The Turnstile check
Cloudflare's siteverify endpoint takes the secret, the token and, optionally, the visitor's IP. A token is valid for 300 seconds and can be checked once.
import "server-only";
const SITEVERIFY = "https://challenges.cloudflare.com/turnstile/v0/siteverify";
export async function passesTurnstile(token: string, visitor?: string) {
if (!token) return false;
const body = new URLSearchParams({ secret: process.env.TURNSTILE_SECRET_KEY ?? "", response: token });
if (visitor) body.set("remoteip", visitor);
const response = await fetch(SITEVERIFY, { method: "POST", body }).catch(() => null);
const outcome = (await response?.json().catch(() => null)) as { success?: boolean } | null;
return outcome?.success === true;
}The server action
"use server";
import { headers } from "next/headers";
import { passesTurnstile } from "@/lib/turnstile";
export interface SignupState {
status: "idle" | "subscribed" | "error";
field?: "email" | "consent" | "check";
message: string;
email: string;
firstName: string;
}
const PLACEMENTS = ["footer", "post", "newsletter-page"];
export async function subscribe(_previous: SignupState, formData: FormData): Promise<SignupState> {
const email = String(formData.get("email") ?? "").trim();
const firstName = String(formData.get("first_name") ?? "").trim().slice(0, 80);
const kept = { email, firstName };
if (formData.get("consent") !== "true") {
return { ...kept, status: "error", field: "consent", message: "Tick the box so we know you want the emails." };
}
const request = await headers();
const visitor = request.get("x-forwarded-for")?.split(",")[0]?.trim();
if (!(await passesTurnstile(String(formData.get("cf-turnstile-response") ?? ""), visitor))) {
return { ...kept, status: "error", field: "check", message: "The human check didn't pass. Try once more." };
}
const source = String(formData.get("source") ?? "");
let response: Response;
try {
response = await fetch("https://ikiform.com/f/frm_xxxxxxxxxxxxxxxx", {
method: "POST",
headers: {
Accept: "application/json",
"Content-Type": "application/json",
Authorization: `Bearer ${process.env.IKIFORM_SECRET_KEY}`,
Origin: request.get("origin") ?? "",
},
body: JSON.stringify({
email,
first_name: firstName || undefined,
consent: true,
source: PLACEMENTS.includes(source) ? source : "footer",
_gotcha: String(formData.get("_gotcha") ?? ""),
}),
signal: AbortSignal.timeout(8000),
});
} catch {
return { ...kept, status: "error", message: "We couldn't reach the list. Try again in a moment." };
}
const result = await response.json().catch(() => null);
if (result?.ok) {
return { status: "subscribed", message: "Almost done. Open the email we sent you and confirm your address.", email: "", firstName: "" };
}
const emailError = result?.error?.fields?.email?.[0];
if (emailError) return { ...kept, status: "error", field: "email", message: emailError };
return { ...kept, status: "error", message: result?.error?.message ?? "Couldn't subscribe. Try again." };
}- Consent is checked first because it costs nothing. The Turnstile call comes second.
sourceis matched against a fixed list. A crafted post can't fill your placement report with junk.- The
Originline passes the browser's origin on, because Ikiform recognises freelocalhosttests by that header and a server'sfetchsends none. - Every error returns the typed email and name. React resets a form after an action, and
defaultValueputs them back. - The honeypot is forwarded untouched. Ikiform stores a filled one as spam and answers with success, so a bot learns nothing.
IKIFORM_SECRET_KEY in server-only environment variables.The form and its pending state
"use client";
import { useFormStatus } from "react-dom";
export function SubmitButton() {
const { pending } = useFormStatus();
return <button disabled={pending}>{pending ? "Subscribing" : "Subscribe"}</button>;
}"use client";
import Script from "next/script";
import { useActionState, useEffect, useId, useRef } from "react";
import { type SignupState, subscribe } from "./actions";
import { SubmitButton } from "./submit-button";
declare global {
interface Window {
turnstile?: { render(box: HTMLElement, options: { sitekey: string }): string; reset(id: string): void };
}
}
const initial: SignupState = { status: "idle", message: "", email: "", firstName: "" };
export function NewsletterForm({ source }: { source: "footer" | "post" | "newsletter-page" }) {
const [state, action] = useActionState(subscribe, initial);
const id = useId();
const box = useRef<HTMLDivElement>(null);
const widget = useRef<string | null>(null);
function mountCheck() {
if (!box.current || !window.turnstile || widget.current) return;
widget.current = window.turnstile.render(box.current, {
sitekey: process.env.NEXT_PUBLIC_TURNSTILE_SITE_KEY ?? "",
});
}
useEffect(mountCheck, []);
useEffect(() => {
if (state.status === "error" && widget.current) window.turnstile?.reset(widget.current);
}, [state]);
if (state.status === "subscribed") return <p role="status">{state.message}</p>;
return (
<form action={action}>
<Script src="https://challenges.cloudflare.com/turnstile/v0/api.js?render=explicit" onLoad={mountCheck} />
<label htmlFor={`${id}-email`}>Email</label>
<input
id={`${id}-email`}
name="email"
type="email"
required
autoComplete="email"
defaultValue={state.email}
aria-invalid={state.field === "email"}
/>
<label htmlFor={`${id}-name`}>First name (optional)</label>
<input id={`${id}-name`} name="first_name" maxLength={80} autoComplete="given-name" defaultValue={state.firstName} />
<label>
<input name="consent" type="checkbox" value="true" required aria-invalid={state.field === "consent"} />
Email me the newsletter. I can unsubscribe at any time.
</label>
<input type="hidden" name="source" value={source} />
<input name="_gotcha" type="text" tabIndex={-1} autoComplete="off" hidden />
<div ref={box} />
{state.status === "error" ? <p role="alert">{state.message}</p> : null}
<SubmitButton />
</form>
);
}useFormStatusmust be read in a child of the<form>, which is why the button is its own component.useIdkeeps input ids unique when the footer form and a/newsletterpage form are on screen together.- The Turnstile script loads with
?render=explicit.renderputs the widget into the form, which then posts a hiddencf-turnstile-responseinput. Explicit rendering also works for forms that mount after a client-side navigation. - A token can be checked once, so the widget is reset after every error. Without the reset, the second try always fails.
Put it in the layout
import { NewsletterForm } from "./newsletter/newsletter-form";
export default function RootLayout({ children }: LayoutProps<"/">) {
return (
<html lang="en">
<body>
{children}
<footer>
<p>One email a month about what we shipped. No tracking pixels.</p>
<NewsletterForm source="footer" />
</footer>
</body>
</html>
);
}Use source="post" at the end of articles and source="newsletter-page" on a dedicated page. LayoutProps is the route-aware type Next.js generates for layouts.
Forward subscribers to Kit
The route handler below receives Ikiform's webhook. It reads the body as text first, because the signature covers the exact bytes. Add its URL under Webhooks, pick the newsletter form and copy the whsec_ secret. The Kit guide covers tags and what each Kit answer means.
import { verifyWebhook, WebhookVerificationError } from "@ikiform/sdk/webhooks";
function kit(path: string, body: Record<string, unknown>) {
return fetch(`https://api.kit.com/v4${path}`, {
method: "POST",
headers: {
"X-Kit-Api-Key": process.env.KIT_API_KEY ?? "",
"Content-Type": "application/json",
Accept: "application/json",
},
body: JSON.stringify(body),
});
}
export async function POST(request: Request) {
let event;
try {
event = await verifyWebhook({
payload: await request.text(),
headers: request.headers,
secret: process.env.IKIFORM_WEBHOOK_SECRET ?? "",
});
} catch (error) {
if (error instanceof WebhookVerificationError) return new Response(error.reason, { status: 400 });
throw error;
}
if (event.type !== "submission.created") return new Response(null, { status: 204 });
const { email, first_name, consent } = event.data.fields;
if (consent !== true || typeof email !== "string") return new Response("Skipped", { status: 200 });
const created = await kit("/subscribers", {
email_address: email,
first_name: typeof first_name === "string" ? first_name : null,
state: "inactive",
});
const added = created.ok
? await kit(`/forms/${process.env.KIT_FORM_ID}/subscribers`, { email_address: email })
: created;
if (added.status === 429) return new Response("Kit rate limit", { status: 503, headers: { "retry-after": "60" } });
if (!added.ok) return new Response(`Kit answered ${added.status}`, { status: 502 });
return new Response(null, { status: 204 });
}- If you have a
proxy.ts, formerlymiddleware.ts, that redirects visitors without a session, keep/api/ikiformout of itsmatcher. Otherwise every delivery gets your sign-in redirect instead of the handler. - With
trailingSlash: trueinnext.config, Next.js redirects/api/ikiformto/api/ikiform/. Register the URL with the slash. - A
503withRetry-Aftermakes Ikiform wait and try again, and a502puts Kit's status in the delivery log.
Rendering and progressive enhancement
Nothing in the form reads the request during render, so the layout doesn't make your static pages dynamic. The action runs per request whatever the page's rendering mode. Because the action goes straight to action, React renders a real form post, and a reader who submits before the JavaScript loads still reaches the action. The Turnstile widget does need JavaScript, so with scripts off the reader gets the human check message instead of a signup. If that trade is wrong for your readers, drop the check and rely on the honeypot and the required box.
Deploy
| Host | What to do |
|---|---|
| Vercel | Add the six variables under the project's Environment Variables. Point the Ikiform webhook at your production domain; preview deployments can sit behind Vercel's Deployment Protection, which turns the webhook away. |
| Netlify | Netlify builds Next.js with its own adapter. Add the variables in the site's environment settings, scoped to Functions, and redeploy. |
| Cloudflare | Run Next.js on Workers through OpenNext. Set the variables in the Worker's settings, and give the Worker a custom domain: Ikiform refuses workers.dev webhook URLs. |
Test it on localhost for free
A workspace without a plan accepts 50 test submissions a month from localhost. Thanks to the forwarded Origin, next dev on http://localhost:3000 counts as one. Use Cloudflare's published Turnstile testing keys in .env.local so the check always passes on your machine. Webhooks can't reach localhost; to try the route handler, expose it with a tunnel that gives an HTTPS URL, or use Send test in the dashboard once it's deployed.
Check it works
- Run
next dev, scroll to the footer and subscribe with an address you own. The check-your-inbox line replaces the form. - Open Submissions. The signup has
consentset totrueandsourceset tofooter. - Remove
requiredfrom the checkbox in the dev tools and submit unticked. The consent message appears and nothing is stored. - Block
challenges.cloudflare.comin the dev tools and submit. The human check message appears and nothing is stored. - If you added a webhook, open the endpoint in Webhooks. The delivery shows
succeededwith your server's answer. - Open Kit. The subscriber is there as inactive, and Kit's confirmation email is in your inbox.
- Post with the honeypot filled in,
_gotcha=bot. It still gets a success answer, but the submission is stored as spam and sends no email or webhook.
Gotchas
| Problem | What to do |
|---|---|
402 plan_required on every local test | The post reached Ikiform without a localhost origin. Check the Origin line in the action, and open the app at localhost, not your network address. |
| The human check fails on every try | The site key and secret come from different widgets, or the widget isn't set up for this hostname. Testing keys only pass with the testing secret. |
| Every static page turned dynamic after adding the footer | Something in the form or layout calls headers() or cookies() while rendering. Read the request only inside the action. |
| Failed to find Server Action, right after a deploy | A tab still runs the previous build, whose action ids changed. The reader reloads and it works. |
Webhook deliveries show 307 or 308 | A proxy redirect or the trailing slash setting caught /api/ikiform. Exclude it from the matcher or register the URL with the slash. |
| The submission is in the dashboard but no email or webhook arrived | It was stored as spam. Spam sends nothing. Open it under the Spam filter to see the reasons, and mark it as not spam to send its email and webhooks. |
Questions
No. Ikiform answers spam with the same success as a real signup, so the action can't tell them apart. Let Ikiform's webhook drive the email tool: it is only sent for signups that aren't spam.
Not with a secret key, which skips it. Either run Turnstile yourself in the action, as on this page, or post from the browser with @ikiform/sdk, which keeps Ikiform's check, allowed domains and per-visitor limits.
No, there is no free plan. Testing is free: you can send 50 test submissions a month from localhost before you pay. To go live, Pro starts at $6/mo for 500 submissions, or you can pay once for 10,000 submissions for $49 or 50,000 submissions for $149, with credits that never expire.
Stuck on a step, or want a guide for your situation? Email hello@ikiform.com.