# Newsletter signup form: HTML, React and Next.js code

> A newsletter signup form with an explicit consent checkbox. Ikiform validates and stores each signup, then hands it to your email tool by webhook.

Updated 2026-10-24 · https://ikiform.com/forms/newsletter-signup

## Copy the code

Every version below is generated from the same field definition. Replace `frm_xxxxxxxxxxxxxxxx` with your form id, or enter it in the **Form ID** box and the code updates.

**HTML** (`newsletter-signup.html`)

```html
<form action="https://ikiform.com/f/frm_xxxxxxxxxxxxxxxx" method="post">
  <label for="email">Email</label>
  <input id="email" name="email" type="email" autocomplete="email" required />
  <label for="first_name">First name</label>
  <input id="first_name" name="first_name" type="text" maxlength="80" />
  <label><input name="consent" type="checkbox" required /> Email me the newsletter. I can unsubscribe at any time.</label>
  <input name="_gotcha" type="text" tabindex="-1" autocomplete="off" hidden />
  <button>Subscribe</button>
</form>
```

**React** (`NewsletterForm.tsx`)

```tsx
import { submit } from "@ikiform/sdk";
import { type FormEvent, useState } from "react";

const FORM_ID = "frm_xxxxxxxxxxxxxxxx";

type Status = "idle" | "sending" | "sent" | "error";

export function NewsletterForm() {
  const [status, setStatus] = useState<Status>("idle");
  const [errors, setErrors] = useState<Record<string, string[]>>({});
  const [message, setMessage] = useState("");

  async function onSubmit(event: FormEvent<HTMLFormElement>) {
    event.preventDefault();
    setStatus("sending");
    setErrors({});
    const result = await submit(FORM_ID, new FormData(event.currentTarget));
    if (result.ok) return setStatus("sent");
    setErrors(result.error.fields ?? {});
    setMessage(result.error.message);
    setStatus("error");
  }

  if (status === "sent") return <p>Thanks for subscribing.</p>;

  return (
    <form onSubmit={onSubmit}>
      <label htmlFor="email">Email</label>
      <input id="email" name="email" type="email" autoComplete="email" required />
      {errors.email ? <p>{errors.email[0]}</p> : null}
      <label htmlFor="first_name">First name</label>
      <input id="first_name" name="first_name" type="text" maxLength={80} />
      {errors.first_name ? <p>{errors.first_name[0]}</p> : null}
      <label><input name="consent" type="checkbox" required /> Email me the newsletter. I can unsubscribe at any time.</label>
      {errors.consent ? <p>{errors.consent[0]}</p> : null}
      <input name="_gotcha" type="text" tabIndex={-1} autoComplete="off" hidden />
      {status === "error" && !Object.keys(errors).length ? <p role="alert">{message}</p> : null}
      <button disabled={status === "sending"}>{status === "sending" ? "Sending" : "Subscribe"}</button>
    </form>
  );
}
```

**JavaScript** (`newsletter-signup.js`)

```js
const form = document.querySelector('form[action="https://ikiform.com/f/frm_xxxxxxxxxxxxxxxx"]');
const status = document.createElement("p");
status.setAttribute("role", "alert");
form.append(status);

const labels = {
  email: "Email",
  first_name: "First name",
  consent: "Email me the newsletter. I can unsubscribe at any time.",
};

form.addEventListener("submit", async (event) => {
  event.preventDefault();
  const button = form.querySelector("button");
  button.disabled = true;
  status.textContent = "";
  try {
    const response = await fetch(form.action, {
      method: "POST",
      headers: { Accept: "application/json" },
      body: new FormData(form),
    });
    const result = await response.json();
    if (result.ok) {
      form.replaceWith("Thanks for subscribing.");
      return;
    }
    const fields = Object.entries(result.error.fields ?? {});
    status.textContent = fields.length
      ? fields.map(([id, messages]) => `${labels[id] ?? id}: ${messages[0]}`).join(" ")
      : result.error.message;
  } catch {
    status.textContent = "Couldn't reach the server. Try again.";
  }
  button.disabled = false;
});
```

- **HTML** needs no JavaScript. After a successful post the browser is redirected to the form's redirect URL, or to Ikiform's hosted thank-you page.
- **React** uses the SDK: `npm install @ikiform/sdk`. Field errors from the server appear under each field.
- **JavaScript** enhances the HTML form with `fetch`, so the visitor stays on the page.
- The hidden `_gotcha` input is the honeypot. Leave it empty and hidden.

> If you set the form's spam check to **Ikiform**, the HTML form keeps working with no change and the server action skips the check because it sends a secret key. The React and JavaScript versions must get a token before each submit: call `challenge(FORM_ID)` from `@ikiform/sdk` and pass it to `submit` as `turnstileToken`, or add it to the `fetch` body as `cf-turnstile-response`. Without it the post is refused with `403 challenge_required`. See [the Next.js guide](https://ikiform.com/docs/guides/nextjs.md#client-human-check) and [Spam protection](https://ikiform.com/docs/spam.md#human-check).

## The fields

Paste this definition on the form's **Fields** tab in the dashboard. Ikiform then checks every post against it on the server, refuses bad data with `422 validation_failed`, refuses any field that isn't listed, and stores typed values.

| Field id | Type | Required | Accepts |
| --- | --- | --- | --- |
| `email` | `email` | Yes | One valid address |
| `first_name` | `text` | No | Up to 80 characters |
| `consent` | `checkbox` | Yes | Must be checked, stored as `true` |

`Field definition`:

```json
{
  "version": 1,
  "title": "Newsletter",
  "fields": [
    {
      "id": "email",
      "type": "email",
      "label": "Email",
      "required": true
    },
    {
      "id": "first_name",
      "type": "text",
      "label": "First name",
      "validation": {
        "max_length": 80
      }
    },
    {
      "id": "consent",
      "type": "checkbox",
      "label": "Email me the newsletter. I can unsubscribe at any time.",
      "required": true
    }
  ],
  "settings": {
    "submit_label": "Subscribe",
    "success_message": "Thanks for subscribing."
  }
}
```

## Next.js server action

To keep the form working without JavaScript in a Next.js app, post from a server action with a secret key. Create the key under **API keys** with the `submissions:write` scope and keep it out of the browser.

`.env.local`:

```bash
IKIFORM_SECRET_KEY=ik_sk_your_secret_key
```

`app/newsletter-signup/actions.ts`:

```ts
"use server";

export interface NewsletterFormState {
  ok: boolean;
  message: string;
  fields?: Record<string, string[]>;
  values?: Record<string, string[]>;
}

export async function sendNewsletterForm(_previous: NewsletterFormState, formData: FormData): Promise<NewsletterFormState> {
  const body = new URLSearchParams();
  for (const [name, value] of formData) {
    if (typeof value === "string" && !name.startsWith("$ACTION")) body.append(name, value);
  }
  const response = await fetch("https://ikiform.com/f/frm_xxxxxxxxxxxxxxxx", {
    method: "POST",
    headers: {
      Accept: "application/json",
      Authorization: `Bearer ${process.env.IKIFORM_SECRET_KEY}`,
    },
    body,
  });
  const result = await response.json();
  if (result.ok) return { ok: true, message: "Thanks for subscribing." };
  const values = Object.fromEntries([...new Set(body.keys())].map((name) => [name, body.getAll(name)]));
  return { ok: false, message: result.error.message, fields: result.error.fields, values };
}
```

`app/newsletter-signup/form.tsx`:

```tsx
"use client";

import { useActionState } from "react";
import { type NewsletterFormState, sendNewsletterForm } from "./actions";

const initial: NewsletterFormState = { ok: false, message: "" };

export function NewsletterForm() {
  const [state, action, pending] = useActionState(sendNewsletterForm, initial);

  if (state.ok) return <p>{state.message}</p>;

  return (
    <form action={action}>
      <label htmlFor="email">Email</label>
      <input id="email" name="email" type="email" autoComplete="email" required defaultValue={state.values?.email?.[0]} />
      {state.fields?.email ? <p>{state.fields.email[0]}</p> : null}
      <label htmlFor="first_name">First name</label>
      <input id="first_name" name="first_name" type="text" maxLength={80} defaultValue={state.values?.first_name?.[0]} />
      {state.fields?.first_name ? <p>{state.fields.first_name[0]}</p> : null}
      <label><input name="consent" type="checkbox" required defaultChecked={Boolean(state.values?.consent)} /> Email me the newsletter. I can unsubscribe at any time.</label>
      {state.fields?.consent ? <p>{state.fields.consent[0]}</p> : null}
      <input name="_gotcha" type="text" tabIndex={-1} autoComplete="off" hidden />
      {state.message && !state.fields ? <p role="alert">{state.message}</p> : null}
      <button disabled={pending}>{pending ? "Sending" : "Subscribe"}</button>
    </form>
  );
}
```

> A server action posts from your server's IP, so the per-visitor rate limit counts every visitor together. Use the Relaxed or Custom [rate limit](https://ikiform.com/docs/rate-limits.md) for forms you send through a server.

## Set it up

1. Create a form in the [dashboard](https://ikiform.com/dashboard) and copy its id.
2. Paste the field definition on the **Fields** tab.
3. Add your site under **Allowed domains**, plus `localhost` while you develop.
4. Set the rate limit to **Standard, 5 a minute per visitor** if visitors post from the browser. A signup is one post. Standard stops a script from adding addresses in bulk. With the server action, every post comes from your server's IP, so choose Relaxed or Custom instead.
5. Paste the code, send a test and check it arrives in the dashboard.

## Recording consent

A `checkbox` field with `required: true` must be checked, or the post is refused with `This box must be checked`. The stored value is `true`, and each submission keeps its timestamp, origin and referrer, which together record where and when the person agreed.

Write the label as the exact promise you are making. If you change the wording later, older submissions still record that they agreed, but not to which text, so note the date you changed it.

## Ikiform is the intake, not the list

Ikiform doesn't email the person who signs up. Notification emails go to your workspace, not to the subscriber, so there is no built-in confirmation or double opt-in email. Ikiform also doesn't send newsletters or handle unsubscribes. Forward each signup by webhook to the email tool that does, and let it send the confirmation for double opt-in.

| Step | Where it happens |
| --- | --- |
| Validate the address and consent, filter spam | Ikiform |
| Store the signup with its timestamp | Ikiform |
| Add to the list, confirm, unsubscribe | Your email tool, through a webhook |

## One form, many pages

Newsletter forms often sit in a footer on every page. Each submission's metadata includes the referrer, so you can see which page the signup came from without adding a hidden field. Add every domain the form appears on under **Allowed domains**.

> Use `email` for the field id. A disposable address adds 2 to the spam score, which on its own is not enough to mark a signup as spam.

## Where to send it

Every submission can go to a signed [webhook](https://ikiform.com/docs/webhooks.md). These guides cover the tools that suit this form, with the relay code:

- [Send form submissions to Zapier](https://ikiform.com/blog/form-submissions-to-zapier.md)
- [Send form submissions to Make](https://ikiform.com/blog/form-submissions-to-make.md)
- [Send form submissions to n8n](https://ikiform.com/blog/form-submissions-to-n8n.md)
- [Send form submissions to Pipedream](https://ikiform.com/blog/form-submissions-to-pipedream.md)
- [Send form submissions to Google Sheets](https://ikiform.com/blog/form-submissions-to-google-sheets.md)

## Questions

### Can Ikiform send my newsletter?

No. Ikiform collects, validates and stores signups and delivers them by webhook. Sending, confirmation emails and unsubscribes belong in your email tool.

### How do I prove someone agreed to receive emails?

Make the consent checkbox required. Ikiform refuses posts where it isn't checked and stores true with the submission's timestamp, origin and referrer.

### Can I use one form on several websites?

Yes. Add each domain under Allowed domains, up to 20 per form. Subdomains of a listed domain are allowed automatically.
