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

Published 2026-10-25 · https://ikiform.com/guides/newsletter-signup/nextjs

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](https://ikiform.com/guides/newsletter-signup.md#form) 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](https://ikiform.com/docs/guides/nextjs.md).

## 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 |

Diagram: How a Next.js footer signup reaches Ikiform and then Kit

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](https://ikiform.com/guides/newsletter-signup/kit.md) 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`:

```text
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`:

```bash
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](https://ikiform.com/guides/newsletter-signup.md#fields) 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`:

```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`:

```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](https://ikiform.com/docs/rate-limits.md) 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`:

```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`:

```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`:

```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](https://ikiform.com/guides/newsletter-signup/kit.md) covers tags and what each Kit answer means.

`app/api/ikiform/route.ts`:

```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

| 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

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

| 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

### Should the server action add the subscriber to my email tool directly?

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.

### Can I use Ikiform's own human check from a Next.js server action?

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.

### Is Ikiform free?

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.

## More guides

- [Newsletter signup form](https://ikiform.com/guides/newsletter-signup.md): Build a newsletter signup form in your own markup, record consent, block bot signups and pass each subscriber to Kit, MailerLite, beehiiv or any tool.
- [Kit (ConvertKit)](https://ikiform.com/guides/newsletter-signup/kit.md): Connect your own newsletter form to Kit with a relay that creates the subscriber, adds them to a Kit form for the incentive email, and tags the placement.
- [Double opt-in](https://ikiform.com/guides/newsletter-signup/double-opt-in.md): Ikiform doesn't send confirmation emails, so double opt-in runs in your email tool: a relay adds each signup as unconfirmed and the tool sends the link.
- [Astro newsletter form](https://ikiform.com/guides/newsletter-signup/astro.md): An Astro newsletter form that stays on prerendered pages: an Astro Action checks consent, posts to Ikiform, and an endpoint adds readers to MailerLite.
- [Static site](https://ikiform.com/guides/newsletter-signup/static-site.md): Add a newsletter signup form to a Hugo, Jekyll, Eleventy or Astro static site, keep the email tool's API key out of the page and confirm in the tool.
- [MailerLite](https://ikiform.com/guides/newsletter-signup/mailerlite.md): Connect your own signup form to MailerLite: a relay adds new readers as unconfirmed for double opt-in and never asks a subscriber to confirm twice.
