Guides17 min read
Newsletter signup form without Mailchimp's embed
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.
Write the signup form in your own HTML and point it at a form backend instead of pasting your email provider's embed code. The backend validates the address and the consent checkbox, filters bots and sends a signed webhook, and a small relay adds the subscriber through your email tool's API. The email tool then sends the confirmation email, the newsletter and the unsubscribe links, so changing tools later means changing the relay, not every form.
Every email tool gives you an embed: a block of generated markup you paste into your page. It works, but the form now belongs to the tool. It posts straight to that provider, it carries the provider's markup and often its styles and scripts, and the day you move to another tool you hunt down every copy of it across your site. This guide builds a signup form you own, in the markup and framework you already use, and connects it to whichever tool sends your newsletter.
How the signup travels
There are two jobs here, and they belong in different places. Collecting a signup is a form job: validate the address, check the consent box, keep bots out, record when it happened. Sending a newsletter is an email job: confirm the address, send issues, handle unsubscribes and bounces. Ikiform does the first. Your email tool does the second. A signed webhook connects them.
- A reader fills in the signup form on your page, and it posts to your Ikiform endpoint.
- Ikiform checks the address, the required consent checkbox, the honeypot, the spam score and the rate limit.
- The accepted signup is stored in the dashboard with its timestamp, origin and referrer.
- Ikiform can email you about each signup. It never emails the reader.
- Ikiform sends a signed submission.created webhook.
- A relay verifies the signature and adds the reader to your email tool, which sends the confirmation and, later, every issue.
Four ways to collect newsletter signups
| Approach | Good at | Trade-offs |
|---|---|---|
| The email tool's embed, from Mailchimp, Kit, MailerLite or beehiiv | Nothing to run. Confirmation, tagging and the welcome email are wired up in the tool's own settings. | The markup and behaviour are the tool's. Mailchimp notes its embedded forms inherit your site's styles, so they can look different on your page, and turning on its reCAPTCHA for an existing form means generating and pasting new code. Every copy is tied to one provider. |
| Your own server route that calls the tool's API | Full control of the form and the request, with the API key kept on your server. | You build validation, bot filtering, rate limits and a record of consent yourself, and you need a server even if the site is static. |
| A hosted form builder plus an automation, such as Typeform with Zapier | No code, and the automation tool knows every email provider. | An iframe that doesn't match your page, plus two subscriptions. Webhooks and branding depend on the plan. |
| A form backend, such as Ikiform, plus a relay | Your own markup on any site, spam filtering, a stored consent record and signed webhooks, with no server for the form. | Ikiform sends no email to the reader. Confirmation and every newsletter come from your email tool, through a small relay or an automation. |
Happy with one site, one tool and its embed? Keep it. Build your own when the form must match your design, spans several sites, or may outlive the tool.
The form
A good newsletter form is small: an email, an optional first name for the greeting, and a consent checkbox whose label is the promise you're making. A hidden source input records where on the site the signup happened. Pick your stack; the choice carries over to every code sample in these guides.
Paste the form into your footer, sidebar or the end of a post. It posts the plain way and lands on your own thank-you page, so it works with no JavaScript.
<form id="newsletter" action="https://ikiform.com/f/frm_xxxxxxxxxxxxxxxx" method="post">
<label for="nl-email">Email</label>
<input id="nl-email" name="email" type="email" required autocomplete="email" placeholder="you@example.com" />
<label for="nl-name">First name (optional)</label>
<input id="nl-name" name="first_name" type="text" maxlength="80" autocomplete="given-name" />
<label>
<input name="consent" type="checkbox" value="true" required />
Email me the newsletter. I can unsubscribe at any time.
</label>
<input type="hidden" name="source" value="footer" />
<input type="hidden" name="_redirect" value="https://example.com/subscribed" />
<input name="_gotcha" type="text" tabindex="-1" autocomplete="off" hidden />
<button>Subscribe</button>
<p id="nl-status" role="status" hidden></p>
</form>To keep the reader on the article, add this script after the form. It posts with fetch, replaces the form with a line that tells them to check their inbox, and shows the server's message for the email or the checkbox.
const form = document.querySelector("#newsletter");
const status = document.querySelector("#nl-status");
const button = form.querySelector("button");
form.addEventListener("submit", async (event) => {
event.preventDefault();
const body = new FormData(form);
button.disabled = true;
status.hidden = false;
status.textContent = "Subscribing";
try {
const response = await fetch(form.action, { method: "POST", headers: { Accept: "application/json" }, body });
const result = await response.json();
if (result.ok) {
form.replaceChildren(status);
status.textContent = "Almost done. Open the email we sent you and confirm your address.";
return;
}
const fields = result.error.fields ?? {};
status.textContent = fields.email?.[0] ?? fields.consent?.[0] ?? result.error.message;
} catch {
status.textContent = "Couldn't reach the server. Try again.";
}
button.disabled = false;
});A server action forwards the signup with your secret key. It returns errors per field, so the email message sits under the email input and the consent message under the checkbox. The component takes a source prop, so the footer and an in-article box record different values.
"use server";
import { headers } from "next/headers";
export interface SignupState {
done: boolean;
errors: { email?: string; consent?: string; form?: string };
email: string;
}
export async function subscribe(_previous: SignupState, formData: FormData): Promise<SignupState> {
const email = String(formData.get("email") ?? "").trim();
const consent = formData.get("consent") === "true";
if (!consent) return { done: false, errors: { consent: "Tick the box so we know you want the emails." }, email };
const 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: (await headers()).get("origin") ?? "",
},
body: JSON.stringify({
email,
first_name: String(formData.get("first_name") ?? "").trim() || undefined,
consent,
source: String(formData.get("source") ?? "footer"),
_gotcha: String(formData.get("_gotcha") ?? ""),
}),
});
const result = await response.json().catch(() => null);
if (result?.ok) return { done: true, errors: {}, email };
const fields = result?.error?.fields ?? {};
return {
done: false,
errors: {
email: fields.email?.[0],
consent: fields.consent?.[0],
form: fields.email || fields.consent ? undefined : (result?.error?.message ?? "Couldn't subscribe. Try again."),
},
email,
};
}"use client";
import { useActionState } from "react";
import { type SignupState, subscribe } from "./actions";
const initial: SignupState = { done: false, errors: {}, email: "" };
export function NewsletterForm({ source = "footer" }: { source?: string }) {
const [state, action, pending] = useActionState(subscribe, initial);
if (state.done) return <p role="status">Almost done. Open the email we sent you and confirm your address.</p>;
return (
<form action={action} noValidate>
<label htmlFor="nl-email">Email</label>
<input
id="nl-email"
name="email"
type="email"
required
autoComplete="email"
defaultValue={state.email}
aria-invalid={Boolean(state.errors.email)}
aria-describedby="nl-email-error"
/>
<p id="nl-email-error">{state.errors.email}</p>
<label htmlFor="nl-name">First name (optional)</label>
<input id="nl-name" name="first_name" type="text" maxLength={80} autoComplete="given-name" />
<label>
<input name="consent" type="checkbox" value="true" aria-describedby="nl-consent-error" />
Email me the newsletter. I can unsubscribe at any time.
</label>
<p id="nl-consent-error">{state.errors.consent}</p>
<input type="hidden" name="source" value={source} />
<input name="_gotcha" type="text" tabIndex={-1} autoComplete="off" hidden />
{state.errors.form ? <p role="alert">{state.errors.form}</p> : null}
<button disabled={pending}>{pending ? "Subscribing" : "Subscribe"}</button>
</form>
);
}Going further with Next.js? Read Build a newsletter signup form in Next.js.
An Astro Action with accept: "form" parses the checkbox as a boolean and the email with z.email(). isInputError tells a bad address apart from a refused post. Form actions need an on-demand page, so add an adapter and set export const prerender = false in every page that renders the component; the export does nothing inside a component.
import { ActionError, defineAction } from "astro:actions";
import { getSecret } from "astro:env/server";
import { z } from "astro/zod";
export const server = {
subscribe: defineAction({
accept: "form",
input: z.object({
email: z.email(),
first_name: z.string().max(80).nullish(),
consent: z.boolean(),
source: z.string().default("footer"),
_gotcha: z.string().nullish(),
}),
handler: async (input, context) => {
const origin = context.request.headers.get("origin");
if (!input.consent) {
throw new ActionError({ code: "BAD_REQUEST", message: "Tick the box so we know you want the emails." });
}
const response = await fetch("https://ikiform.com/f/frm_xxxxxxxxxxxxxxxx", {
method: "POST",
headers: {
Accept: "application/json",
"Content-Type": "application/json",
Authorization: `Bearer ${getSecret("IKIFORM_SECRET_KEY")}`,
...(origin ? { Origin: origin } : {}),
},
body: JSON.stringify({ ...input, first_name: input.first_name ?? undefined }),
});
const result = await response.json();
if (!result.ok) {
throw new ActionError({
code: "BAD_REQUEST",
message: result.error.fields?.email?.[0] ?? result.error.message,
});
}
return { subscribed: true };
},
}),
};---
import { actions, isInputError } from "astro:actions";
const result = Astro.getActionResult(actions.subscribe);
const fields = isInputError(result?.error) ? result.error.fields : {};
---
{result?.data?.subscribed ? (
<p role="status">Almost done. Open the email we sent you and confirm your address.</p>
) : (
<form method="POST" action={actions.subscribe}>
<label for="nl-email">Email</label>
<input id="nl-email" name="email" type="email" required autocomplete="email" />
{fields.email && <p>Enter a valid email address.</p>}
<label for="nl-name">First name (optional)</label>
<input id="nl-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 type="hidden" name="source" value="footer" />
<input name="_gotcha" type="text" tabindex="-1" autocomplete="off" hidden />
{result?.error && !isInputError(result.error) && <p role="alert">{result.error.message}</p>}
<button>Subscribe</button>
</form>
)}Going further with Astro? Read Build a newsletter signup form in Astro.
A createServerFn POST function turns the FormData into JSON in .validator() and returns field errors as data rather than throwing, so the component can place each message next to its input.
import { createServerFn, useServerFn } from "@tanstack/react-start";
import { getRequestHeader } from "@tanstack/react-start/server";
import { type FormEvent, useState } from "react";
type Errors = { email?: string; consent?: string; form?: string };
const subscribe = createServerFn({ method: "POST" })
.validator((data: unknown) => {
if (!(data instanceof FormData)) throw new Error("Expected form data");
return {
email: String(data.get("email") ?? "").trim(),
first_name: String(data.get("first_name") ?? "").trim() || undefined,
consent: data.get("consent") === "true",
source: String(data.get("source") ?? "footer"),
_gotcha: String(data.get("_gotcha") ?? ""),
};
})
.handler(async ({ data }): Promise<{ ok: boolean; errors: Errors }> => {
const origin = getRequestHeader("origin");
const 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 ? { Origin: origin } : {}),
},
body: JSON.stringify(data),
});
const result = await response.json();
if (result.ok) return { ok: true, errors: {} };
const fields = result.error.fields ?? {};
return {
ok: false,
errors: { email: fields.email?.[0], consent: fields.consent?.[0], form: result.error.message },
};
});
export function NewsletterForm() {
const send = useServerFn(subscribe);
const [done, setDone] = useState(false);
const [errors, setErrors] = useState<Errors>({});
const [pending, setPending] = useState(false);
async function onSubmit(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
setPending(true);
try {
const result = await send({ data: new FormData(event.currentTarget) });
setDone(result.ok);
setErrors(result.errors);
} catch {
setErrors({ form: "Couldn't subscribe. Try again." });
} finally {
setPending(false);
}
}
if (done) return <p role="status">Almost done. Open the email we sent you and confirm your address.</p>;
return (
<form method="post" onSubmit={onSubmit}>
<label htmlFor="nl-email">Email</label>
<input id="nl-email" name="email" type="email" required autoComplete="email" />
{errors.email ? <p>{errors.email}</p> : null}
<label htmlFor="nl-name">First name (optional)</label>
<input id="nl-name" name="first_name" type="text" maxLength={80} />
<label>
<input name="consent" type="checkbox" value="true" required />
Email me the newsletter. I can unsubscribe at any time.
</label>
{errors.consent ? <p>{errors.consent}</p> : null}
<input type="hidden" name="source" value="footer" />
<input name="_gotcha" type="text" tabIndex={-1} autoComplete="off" hidden />
{errors.form && !errors.email && !errors.consent ? <p role="alert">{errors.form}</p> : null}
<button disabled={pending}>{pending ? "Subscribing" : "Subscribe"}</button>
</form>
);
}Going further with TanStack Start? Read Build a newsletter signup form in TanStack Start.
A footer form shouldn't navigate away from the page someone is reading. Put the action in a resource route at /newsletter and post to it with useFetcher, which submits without a navigation and keeps the result on the current page.
import { data } from "react-router";
import type { Route } from "./+types/newsletter";
export async function action({ request }: Route.ActionArgs) {
const formData = await request.formData();
const 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.headers.get("origin") ?? "",
},
body: JSON.stringify({
email: String(formData.get("email") ?? "").trim(),
first_name: String(formData.get("first_name") ?? "").trim() || undefined,
consent: formData.get("consent") === "true",
source: String(formData.get("source") ?? "footer"),
_gotcha: String(formData.get("_gotcha") ?? ""),
}),
});
const result = await response.json();
if (result.ok) return { ok: true as const };
return data(
{ ok: false as const, fields: (result.error.fields ?? {}) as Record<string, string[]>, message: String(result.error.message) },
{ status: response.status },
);
}import { type RouteConfig, index, route } from "@react-router/dev/routes";
export default [index("routes/home.tsx"), route("newsletter", "routes/newsletter.tsx")] satisfies RouteConfig;import { useFetcher } from "react-router";
import type { action } from "~/routes/newsletter";
export function NewsletterForm() {
const fetcher = useFetcher<typeof action>();
const result = fetcher.data;
const busy = fetcher.state !== "idle";
if (result?.ok) return <p role="status">Almost done. Open the email we sent you and confirm your address.</p>;
return (
<fetcher.Form method="post" action="/newsletter">
<label htmlFor="nl-email">Email</label>
<input id="nl-email" name="email" type="email" required autoComplete="email" />
{result?.fields.email ? <p>{result.fields.email[0]}</p> : null}
<label htmlFor="nl-name">First name (optional)</label>
<input id="nl-name" name="first_name" type="text" maxLength={80} />
<label>
<input name="consent" type="checkbox" value="true" required />
Email me the newsletter. I can unsubscribe at any time.
</label>
{result?.fields.consent ? <p>{result.fields.consent[0]}</p> : null}
<input type="hidden" name="source" value="footer" />
<input name="_gotcha" type="text" tabIndex={-1} autoComplete="off" hidden />
{result && !result.fields.email && !result.fields.consent ? <p role="alert">{result.message}</p> : null}
<button disabled={busy}>{busy ? "Subscribing" : "Subscribe"}</button>
</fetcher.Form>
);
}Going further with React Router? Read Build a newsletter signup form in React Router.
Actions live in a +page.server.ts, never in a layout, so the action sits on a /newsletter page and the footer posts to /newsletter?/subscribe. The custom use:enhance callback handles the result itself instead of calling update(), so the reader stays on the article. Declare IKIFORM_SECRET_KEY in src/env.ts with defineEnvVars so $app/env/private can read it.
import { fail } from "@sveltejs/kit";
import { IKIFORM_SECRET_KEY } from "$app/env/private";
import type { Actions } from "./$types";
export const actions = {
subscribe: async ({ request, fetch, url }) => {
const form = await request.formData();
const response = await fetch("https://ikiform.com/f/frm_xxxxxxxxxxxxxxxx", {
method: "POST",
headers: {
Accept: "application/json",
"Content-Type": "application/json",
Authorization: `Bearer ${IKIFORM_SECRET_KEY}`,
Origin: request.headers.get("origin") ?? url.origin,
},
body: JSON.stringify({
email: String(form.get("email") ?? "").trim(),
first_name: String(form.get("first_name") ?? "").trim() || undefined,
consent: form.get("consent") === "true",
source: String(form.get("source") ?? "footer"),
_gotcha: String(form.get("_gotcha") ?? ""),
}),
});
const result = await response.json();
if (!result.ok) {
const fields = result.error.fields ?? {};
return fail(response.status, { message: String(fields.email?.[0] ?? fields.consent?.[0] ?? result.error.message) });
}
return { subscribed: true };
},
} satisfies Actions;<script lang="ts">
import { applyAction, enhance } from "$app/forms";
let done = $state(false);
let message = $state("");
let pending = $state(false);
</script>
{#if done}
<p role="status">Almost done. Open the email we sent you and confirm your address.</p>
{:else}
<form
method="POST"
action="/newsletter?/subscribe"
use:enhance={() => {
pending = true;
return async ({ result }) => {
pending = false;
if (result.type === "success") done = true;
else if (result.type === "failure") message = String(result.data?.message ?? "Couldn't subscribe.");
else await applyAction(result);
};
}}
>
<label for="nl-email">Email</label>
<input id="nl-email" name="email" type="email" required autocomplete="email" />
<label for="nl-name">First name (optional)</label>
<input id="nl-name" name="first_name" type="text" maxlength="80" />
<label>
<input name="consent" type="checkbox" value="true" required />
Email me the newsletter. I can unsubscribe at any time.
</label>
<input type="hidden" name="source" value="footer" />
<input name="_gotcha" type="text" tabindex="-1" autocomplete="off" hidden />
{#if message}
<p role="alert">{message}</p>
{/if}
<button disabled={pending}>{pending ? "Subscribing" : "Subscribe"}</button>
</form>
{/if}Going further with SvelteKit? Read Build a newsletter signup form in SvelteKit.
A server route keeps the secret key out of the browser and passes Ikiform's field errors back in the error's data. Set NUXT_IKIFORM_SECRET_KEY, and declare ikiformSecretKey under runtimeConfig in nuxt.config.ts.
interface Signup {
email?: string;
first_name?: string;
consent?: boolean;
source?: string;
_gotcha?: string;
}
export default defineEventHandler(async (event) => {
const body = await readBody<Signup>(event);
const config = useRuntimeConfig(event);
const origin = getRequestHeader(event, "origin");
const response = await fetch("https://ikiform.com/f/frm_xxxxxxxxxxxxxxxx", {
method: "POST",
headers: {
Accept: "application/json",
"Content-Type": "application/json",
Authorization: `Bearer ${config.ikiformSecretKey}`,
...(origin ? { Origin: origin } : {}),
},
body: JSON.stringify({
email: body.email?.trim() ?? "",
first_name: body.first_name?.trim() || undefined,
consent: body.consent === true,
source: body.source ?? "footer",
_gotcha: body._gotcha ?? "",
}),
});
const result = await response.json();
if (!result.ok) {
throw createError({
status: response.status,
message: String(result.error.message),
data: { fields: result.error.fields ?? {} },
});
}
return { subscribed: true };
});<script setup lang="ts">
const email = ref("");
const firstName = ref("");
const consent = ref(false);
const trap = ref("");
const status = ref<"idle" | "sending" | "done">("idle");
const errors = ref<Record<string, string[]>>({});
const message = ref("");
async function subscribe() {
status.value = "sending";
errors.value = {};
message.value = "";
try {
await $fetch("/api/newsletter", {
method: "POST",
body: { email: email.value, first_name: firstName.value, consent: consent.value, source: "footer", _gotcha: trap.value },
});
status.value = "done";
} catch (failure) {
const data = (failure as { data?: { message?: string; data?: { fields?: Record<string, string[]> } } }).data;
errors.value = data?.data?.fields ?? {};
message.value = data?.message ?? "Couldn't subscribe. Try again.";
status.value = "idle";
}
}
</script>
<template>
<p v-if="status === 'done'" role="status">Almost done. Open the email we sent you and confirm your address.</p>
<form v-else @submit.prevent="subscribe">
<label for="nl-email">Email</label>
<input id="nl-email" v-model="email" name="email" type="email" required autocomplete="email" />
<p v-if="errors.email">{{ errors.email[0] }}</p>
<label for="nl-name">First name (optional)</label>
<input id="nl-name" v-model="firstName" name="first_name" type="text" maxlength="80" />
<label>
<input v-model="consent" name="consent" type="checkbox" required />
Email me the newsletter. I can unsubscribe at any time.
</label>
<p v-if="errors.consent">{{ errors.consent[0] }}</p>
<input v-model="trap" name="_gotcha" type="text" tabindex="-1" autocomplete="off" hidden />
<p v-if="message && !errors.email && !errors.consent" role="alert">{{ message }}</p>
<button :disabled="status === 'sending'">{{ status === "sending" ? "Subscribing" : "Subscribe" }}</button>
</form>
</template>Going further with Nuxt? Read Build a newsletter signup form in Nuxt.
A single-page app posts from the browser. submit() from @ikiform/sdk sends the FormData and always resolves with ok and, on failure, the field errors, so there is no try block to write. Never put a secret key here; Allowed domains protects the form instead.
import { submit } from "@ikiform/sdk";
import { type FormEvent, useState } from "react";
type FieldErrors = Record<string, string[]>;
export function NewsletterForm() {
const [done, setDone] = useState(false);
const [errors, setErrors] = useState<FieldErrors>({});
const [message, setMessage] = useState("");
const [pending, setPending] = useState(false);
async function onSubmit(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
setPending(true);
setErrors({});
setMessage("");
const result = await submit("frm_xxxxxxxxxxxxxxxx", new FormData(event.currentTarget));
setPending(false);
if (result.ok) {
setDone(true);
return;
}
setErrors(result.error.fields ?? {});
setMessage(result.error.message);
}
if (done) return <p role="status">Almost done. Open the email we sent you and confirm your address.</p>;
return (
<form action="https://ikiform.com/f/frm_xxxxxxxxxxxxxxxx" method="post" onSubmit={onSubmit}>
<label htmlFor="nl-email">Email</label>
<input id="nl-email" name="email" type="email" required autoComplete="email" />
{errors.email ? <p>{errors.email[0]}</p> : null}
<label htmlFor="nl-name">First name (optional)</label>
<input id="nl-name" name="first_name" type="text" maxLength={80} />
<label>
<input name="consent" type="checkbox" value="true" required />
Email me the newsletter. I can unsubscribe at any time.
</label>
{errors.consent ? <p>{errors.consent[0]}</p> : null}
<input type="hidden" name="source" value="footer" />
<input name="_gotcha" type="text" tabIndex={-1} autoComplete="off" hidden />
{message && !errors.email && !errors.consent ? <p role="alert">{message}</p> : null}
<button disabled={pending}>{pending ? "Subscribing" : "Subscribe"}</button>
</form>
);
}The same submit() call in a Vue single-file component. The form keeps its action, so it still posts if the script fails to load.
<script setup lang="ts">
import { submit } from "@ikiform/sdk";
import { ref } from "vue";
const status = ref<"idle" | "sending" | "done">("idle");
const errors = ref<Record<string, string[]>>({});
const message = ref("");
async function subscribe(event: SubmitEvent) {
status.value = "sending";
const result = await submit("frm_xxxxxxxxxxxxxxxx", new FormData(event.target as HTMLFormElement));
if (result.ok) {
status.value = "done";
return;
}
errors.value = result.error.fields ?? {};
message.value = result.error.message;
status.value = "idle";
}
</script>
<template>
<p v-if="status === 'done'" role="status">Almost done. Open the email we sent you and confirm your address.</p>
<form v-else action="https://ikiform.com/f/frm_xxxxxxxxxxxxxxxx" method="post" @submit.prevent="subscribe">
<label for="nl-email">Email</label>
<input id="nl-email" name="email" type="email" required autocomplete="email" />
<p v-if="errors.email">{{ errors.email[0] }}</p>
<label for="nl-name">First name (optional)</label>
<input id="nl-name" name="first_name" type="text" maxlength="80" />
<label>
<input name="consent" type="checkbox" value="true" required />
Email me the newsletter. I can unsubscribe at any time.
</label>
<p v-if="errors.consent">{{ errors.consent[0] }}</p>
<input type="hidden" name="source" value="footer" />
<input name="_gotcha" type="text" tabindex="-1" autocomplete="off" hidden />
<p v-if="message && !errors.email && !errors.consent" role="alert">{{ message }}</p>
<button :disabled="status === 'sending'">{{ status === "sending" ? "Subscribing" : "Subscribe" }}</button>
</form>
</template>- Replace
frm_xxxxxxxxxxxxxxxxwith your form's id from the dashboard. - Keep the email input's name as
email. Ikiform validates it, lowercases the domain, and checks it against disposable email domains. - The hidden
_gotchainput is the honeypot. People never see it, and bots that fill in every input mark themselves as spam. - The checkbox posts
truewhen ticked. Unticked, it posts nothing, and a required checkbox is refused withThis box must be checked. - The success line says to check the inbox, because your email tool sends a confirmation. If you turned double opt-in off, change it to a plain thank you.
- The server-side versions pass the browser's
Originheader on, because Ikiform recognises freelocalhosttests by it and a server's ownfetchsends none.
IKIFORM_SECRET_KEY in server-only environment variables.Framework setup lives in the docs: Next.js, Astro, SvelteKit, Nuxt, React, Vue and plain HTML. The newsletter form template generates the same fields as HTML, React and a Next.js server action.
Fields and the definition
Paste the definition below on the form's Fields tab and Ikiform enforces it on the server, whatever the page sends. The preview runs the same validator in your browser: leave the box unticked and submit to see the error a bot or a broken page would get.
Preview
Runs Ikiform's validator in your browser. Nothing is sent.
source is a hidden input. It's a visible text field here only so you can try it.| Field | Why | Keep in mind |
|---|---|---|
email | The subscriber | Required. type="email" brings up the right phone keyboard |
first_name | A greeting in each issue | Optional. Most email tools have a first name field to map it to |
consent | A record that the person asked for email | Required checkbox. Stored as true |
source | Which form on the site worked: footer, post, popup | Hidden. Set a different value per placement, and send it on as a tag or field |
With fields defined, Ikiform refuses any input it doesn't know with unknown_field, so add a field to the definition before you add an input to the page. Names that start with _, such as _redirect, are instructions to Ikiform and never count as fields.
Recording consent
A newsletter is email someone asked for. The checkbox makes the asking explicit, and the stored submission records it. This is what Ikiform keeps for each accepted signup:
{
"email": "ada@example.com",
"first_name": "Ada",
"consent": true,
"source": "footer"
}Next to the fields, each submission has its created_at timestamp, the page's origin and referrer, the country, and the browser's user agent. Together they show when and where the person ticked the box. Two habits make that record useful later:
- Write the label as the promise. "Email me the newsletter. I can unsubscribe at any time." says what they get. If you also send offers, say so in the label.
- Note the date when the wording changes. A stored
truesays the person agreed, not to which text. Keep a short changelog of the label with dates, so any signup can be matched to the words it saw.
Ikiform keeps the record; it can't tell you what the rules where you and your readers live require. Check those with someone who knows them.
Confirmation emails come from your email tool
Ikiform doesn't email the person who fills in the form. Notification emails go to you and your team only, so there is no autoresponder, confirmation email or double opt-in. Send those from your email tool, triggered by a webhook. That split is on purpose. The tool that sends your newsletter already tracks who confirmed, who unsubscribed and which addresses bounce. If a second system sent confirmations, those records would drift apart.
So the relay adds the reader in a state that makes the email tool send its own confirmation, and the tool marks them active when they click. Each tool switches this on differently: a form setting, an account toggle, or a flag on the API request. The double opt-in guide covers all three, and what happens when a known subscriber signs up again.
The relay
A relay is a short function that receives Ikiform's webhook, checks its signature and calls your email tool's API. It needs a public HTTPS URL and one secret per side. This one is a Next.js route handler; the same code runs in any runtime with fetch.
import { verifyWebhook, WebhookVerificationError } from "@ikiform/sdk/webhooks";
import { addSubscriber } from "@/lib/email-tool";
export async function POST(request: Request) {
const payload = await request.text();
let event;
try {
event = await verifyWebhook({ payload, 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 fields = event.data.fields;
if (fields.consent !== true) return new Response("No consent, skipped", { status: 200 });
const answer = await addSubscriber({
email: String(fields.email),
firstName: typeof fields.first_name === "string" ? fields.first_name : undefined,
source: typeof fields.source === "string" ? fields.source : "unknown",
referrer: typeof event.data.meta.referrer === "string" ? event.data.meta.referrer : undefined,
});
if (answer.status === 429) {
return new Response("Email tool rate limit", {
status: 503,
headers: { "retry-after": answer.headers.get("retry-after") ?? "60" },
});
}
if (!answer.ok) return new Response(`Email tool answered ${answer.status}`, { status: 502 });
return new Response(null, { status: 204 });
}verifyWebhookchecks the HMAC signature and the timestamp. Without it, anyone who finds the URL can add addresses to your list.endpoint.testevents, from Send test in the dashboard, are answered204and skipped.- The consent check is a second lock behind the form definition.
addSubscriberis the only part that depends on the tool. Its body for each tool is on the Kit, MailerLite and beehiiv pages.
What the relay answers decides what Ikiform does next:
- The email tool adds or updates the subscriber: the relay answers 204 and the delivery is marked succeeded.
- The email tool is rate limited: the relay answers 503 with Retry-After, and Ikiform waits that long before the next attempt.
- The email tool refuses the request or is down: the relay answers 502, and Ikiform retries on its schedule with the same webhook-id.
Add the relay's URL under Webhooks in the dashboard, pick the newsletter form, and copy the whsec_ secret into IKIFORM_WEBHOOK_SECRET. A workspace has up to 3 endpoints. The URL has to be public HTTPS on port 443; workers.dev and pages.dev hostnames are refused, so give a Worker a custom domain. Retries, payloads and the signature scheme are on the webhooks docs, and form submissions to your own server has a version that stores each delivery once.
Keep bots and list bombing out
Bot signups hurt a newsletter more than most forms. A real address signed up by a bot gets email it never asked for, and complaints from those people hurt the domain you send from. Some bots sign one victim's address up to hundreds of forms at once to bury their inbox, and every form with a confirmation email takes part.
| Defence | What it stops | Setup |
|---|---|---|
| Honeypot | Bots that fill in every input | The hidden _gotcha input |
| Required consent | Scripts that post only an email | The consent checkbox with required: true in the definition |
| Spam scoring | Disposable addresses (+2), identical repeats (+3), links and spam phrases. At 5 a signup is stored as spam | None |
| Rate limits | One visitor posting in a loop | On by default: Standard, 5 a minute per visitor |
| Human check | Bots that run a real browser | Set Spam check to Ikiform for Cloudflare Turnstile with no keys to manage |
| Allowed domains | Your form's endpoint copied onto other sites | List every domain the form appears on |
Spam gets the same success answer as a real signup and sends no webhook, so it never reaches your email tool and never triggers a confirmation email. If a real reader lands in spam, mark it as not spam and its webhook goes out then. The spam docs explain each check and its score.
One form on many pages
One endpoint can sit in the footer of every page, at the end of each post and on a /newsletter page. Give each placement its own source value to learn which one earns signups:
footer: seen on every page. Keep it to the email and the checkbox.post: readers who finished an article, often the best converter.newsletter-page: where social profiles link. Room for a sample issue.
Every submission also records the referrer, which is the exact page the reader was on, so source tells you the placement and the referrer tells you the article. Add each domain that shows the form under Allowed domains, up to 20 per form; subdomains of a listed domain are allowed automatically.
The same reader twice
Ikiform doesn't merge or reject repeat submissions by email. An identical repeat within 24 hours only adds to the spam score. Deduplicate where the list lives, or when you export. For a newsletter that's rarely a problem: Kit, MailerLite and beehiiv each key subscribers by email, so a repeat updates one contact instead of creating a second.
Changing email tools later
This is where owning the form pays off. Moving from one email tool to another normally means replacing every embed on the site. Here the form doesn't know which tool sits behind it:
- With an embed, every page carries code that posts straight to the old tool, so moving means replacing each copy.
- With your own form, every page keeps posting to Ikiform, and Ikiform keeps sending the same webhook.
- Only the relay's addSubscriber function changes, to call the new tool's API.
Move existing subscribers with the old tool's own export, since only it knows who confirmed and who left. Ikiform's CSV export, up to 100,000 rows per file, is for checking the numbers, not for importing.
Pick your situation
The form above is the same everywhere. These pages cover confirmation emails, sites with no server, and the exact API calls for three email tools.
- Double opt-inIkiform 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.
- Static siteAdd 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.
- Kit (ConvertKit)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.
- MailerLiteConnect 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.
- beehiivConnect your own signup form to beehiiv: a relay creates each subscription with double opt-in on, the placement as UTM source and the page as referrer.
- Next.js newsletter formA 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.
- Astro newsletter formAn Astro newsletter form that stays on prerendered pages: an Astro Action checks consent, posts to Ikiform, and an endpoint adds readers to MailerLite.
- TanStack Start newsletter formA TanStack Start newsletter form: a server function for in-page signups, a server route as the no-JavaScript fallback, and real signups sent to beehiiv.
- React Router newsletter formA React Router footer newsletter form: useFetcher posts to a /newsletter route that also works without JavaScript, and a resource route feeds MailerLite.
- SvelteKit newsletter formA SvelteKit 3 newsletter form in the layout: a form action with consent and an IP limit, use:enhance that stays on the page, and a Kit webhook route.
- Nuxt newsletter formA Nuxt newsletter form that works with or without JavaScript: a server route with a Turnstile check posts to Ikiform, and a Nitro route feeds beehiiv.
Check it works
Run through this before you add the form to every page. Ikiform has no free plan. Testing is free: a workspace without a plan accepts 50 test submissions a month from localhost. To go live, pick Pro from $6/mo for 500 submissions, or pay once: 10,000 submissions for $49 or 50,000 submissions for $149, with credits that never expire. See pricing.
- Under the form's Allowed domains, add your site's domain, plus
localhostwhile you develop. Leave it empty to accept posts from anywhere. - Submit the form with the box unticked from the browser's developer tools, or remove
requiredfor a moment. You get422withThis box must be checkedand nothing is stored. - Submit it properly with an address you own. You see the check-your-inbox line, or land on your thank-you page.
- Open Submissions in the dashboard. The new submission is there with every field you sent.
- Open the endpoint under Webhooks. The delivery shows
succeededand your relay's answer. - Open your email tool. The subscriber is there, unconfirmed if double opt-in is on, and the 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. - Nothing arrived? Switch the dashboard's status filter to Spam. Spam gets the same success answer as a real submission.
Troubleshooting
| Problem | What to do |
|---|---|
422 validation_failed with consent | The checkbox wasn't ticked, or its input name isn't consent. A server-side version must send true, "true" or "on"; an unticked box sends nothing. |
422 validation_failed with unknown_field | The page sends an input the definition doesn't list, often a hidden source or a tracking input added later. Add it to the definition, or remove the input. |
403 origin_not_allowed, or every post lands in spam | The page's domain isn't in the form's Allowed domains. Add it, plus localhost for local tests. Subdomains of a listed domain are allowed. |
429 rate_limited | One visitor sent more than the form's rate limit allows. Standard allows 5 a minute per visitor. Wait for Retry-After, or choose Relaxed or Custom for forms that post from one shared IP. |
| 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. |
| Signups arrive in Ikiform but not in the email tool | Read the delivery log under Webhooks. 400 means the relay's secret is wrong. 502 carries the email tool's status: usually a wrong API key, list id or form id. |
| The subscriber is in the email tool but got no confirmation email | Double opt-in is off in the tool, or the relay created the subscriber as already confirmed. See double opt-in for each tool's switch. |
| The browser lands on Ikiform's thank-you page instead of yours | _redirect is ignored unless its domain is in Allowed domains and it is an https: URL. Add the domain, or set Redirect after submit in the form's settings instead. |
Questions
Yes. Write the form in your own HTML, post it to a form backend such as Ikiform, and forward each signup by webhook to Mailchimp or any other email tool through its API. The form then looks like the rest of your site and doesn't change when the email tool does.
No. Ikiform collects, validates and stores signups and sends them on by webhook. It never emails the subscriber, so the confirmation email, every issue and the unsubscribe link come from your email tool.
Any tool with an API to add subscribers, or any tool an automation service such as Zapier, Make or n8n supports. There are no one-click integrations; the relay or the automation does the connecting. Kit, MailerLite and beehiiv have step-by-step pages in this guide.
In the relay's server-side environment variables, never in the page. An email tool's API key can usually read and change the whole list, so a key in browser JavaScript is a key anyone can copy.
Keep the hidden _gotcha honeypot and the required consent checkbox, list your domains under Allowed domains, and set the spam check to Ikiform for a Cloudflare Turnstile check. Signups stored as spam never send a webhook, so they never trigger a confirmation email.
No. Every accepted post is stored. An identical repeat within 24 hours adds to the spam score but isn't spam on its own, so deduplicate by email in your list, database or CSV export.
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.