Newsletter signup form

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.

Preet SutharFounder, Ikiform

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:

ConcernWhat happensWhat this build does
Static pagesThe form must not read cookies or headers while rendering, or every page turns dynamicThe form reads nothing at render. Only the action touches the request
The human checkA post with a secret key skips Ikiform's Turnstile checkRuns its own Turnstile check inside the action
Who can call itA server action is a public POST endpoint, reachable without your formValidates consent and the token before anything is sent
How a Next.js footer signup reaches Ikiform and then Kitwhile the reader waitsseconds laterfooterformrootlayout,everypageserveractionconsent,thenTurnstileIkiformsecretkey,storessignupactionstatecheckyourinboxerrorstatenothingsent,emailkeptIkiformonlyfornon-spam/api/ikiformverifiesthesignatureKitsendstheconfirmationFormDataJSON + Originresultcheck failssigned webhookv4 APIHow a Next.js footer signup reaches Ikiform and then Kitwhile the reader waitsseconds laterfooter formroot layout, everypageserveractionconsent, thenTurnstileIkiformsecret key, storessignupaction statecheck your inboxerrorstatenothingsent, emailkeptIkiformonly for non-spam/api/ikiformverifies thesignatureKitsends theconfirmationFormDataJSON + Originresultsigned webhookv4 APIcheck fails
  1. The reader fills in the footer form. It posts to the server action on whatever page they're reading.
  2. The action checks the consent box, then verifies the Turnstile token with Cloudflare.
  3. If either check fails, the form comes back with the message and the typed email. Nothing is sent.
  4. Otherwise the action posts the signup to Ikiform with the secret key and the browser's Origin.
  5. Ikiform's answer becomes the action state, and the form is replaced with the check-your-inbox line.
  6. 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.
The action never calls Kit itself. Ikiform answers spam with the same success as a real signup, so only the webhook knows which signups are real.

Project setup

Project tree
app/
  layout.tsx
  newsletter/
    actions.ts
    newsletter-form.tsx
    submit-button.tsx
    page.tsx
  api/ikiform/route.ts
lib/
  turnstile.ts
.env.local

Create 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.

.env.local
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=1234567

Paste 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.

lib/turnstile.ts
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

app/newsletter/actions.ts
"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.
  • source is matched against a fixed list. A crafted post can't fill your placement report with junk.
  • The Origin line passes the browser's origin on, because Ikiform recognises free localhost tests by that header and a server's fetch sends none.
  • Every error returns the typed email and name. React resets a form after an action, and defaultValue puts them back.
  • The honeypot is forwarded untouched. Ikiform stores a filled one as spam and answers with success, so a bot learns nothing.
The server-side versions post with a secret key, so Ikiform skips Allowed domains and its human check. Every post then comes from your server's IP, and the Standard limit of 5 a minute per visitor counts all your visitors together. Choose the Relaxed or Custom rate limit for a form you post through a server, and keep IKIFORM_SECRET_KEY in server-only environment variables.

The form and its pending state

app/newsletter/submit-button.tsx
"use client";

import { useFormStatus } from "react-dom";

export function SubmitButton() {
  const { pending } = useFormStatus();
  return <button disabled={pending}>{pending ? "Subscribing" : "Subscribe"}</button>;
}
app/newsletter/newsletter-form.tsx
"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>
  );
}
  • useFormStatus must be read in a child of the <form>, which is why the button is its own component.
  • useId keeps input ids unique when the footer form and a /newsletter page form are on screen together.
  • The Turnstile script loads with ?render=explicit. render puts the widget into the form, which then posts a hidden cf-turnstile-response input. 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

app/layout.tsx
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.

app/api/ikiform/route.ts
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, formerly middleware.ts, that redirects visitors without a session, keep /api/ikiform out of its matcher. Otherwise every delivery gets your sign-in redirect instead of the handler.
  • With trailingSlash: true in next.config, Next.js redirects /api/ikiform to /api/ikiform/. Register the URL with the slash.
  • A 503 with Retry-After makes Ikiform wait and try again, and a 502 puts 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

HostWhat to do
VercelAdd 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.
NetlifyNetlify builds Next.js with its own adapter. Add the variables in the site's environment settings, scoped to Functions, and redeploy.
CloudflareRun 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

  1. Run next dev, scroll to the footer and subscribe with an address you own. The check-your-inbox line replaces the form.
  2. Open Submissions. The signup has consent set to true and source set to footer.
  3. Remove required from the checkbox in the dev tools and submit unticked. The consent message appears and nothing is stored.
  4. Block challenges.cloudflare.com in the dev tools and submit. The human check message appears and nothing is stored.
  5. If you added a webhook, open the endpoint in Webhooks. The delivery shows succeeded with your server's answer.
  6. Open Kit. The subscriber is there as inactive, and Kit's confirmation email is in your inbox.
  7. 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

ProblemWhat to do
402 plan_required on every local testThe 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 tryThe 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 footerSomething 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 deployA tab still runs the previous build, whose action ids changed. The reader reloads and it works.
Webhook deliveries show 307 or 308A 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 arrivedIt 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

Stuck on a step, or want a guide for your situation? Email hello@ikiform.com.