Panduan15 menit baca
Formulir pendaftaran newsletter tanpa embed Mailchimp
Buat formulir pendaftaran newsletter dengan markup Anda sendiri, catat persetujuan, blokir pendaftaran bot, dan teruskan setiap subscriber ke Kit, MailerLite, beehiiv, atau alat apa pun.
Tulis formulir pendaftaran dengan HTML Anda sendiri dan arahkan ke backend formulir alih-alih menempel kode embed dari penyedia email Anda. Backend memvalidasi alamat dan checkbox persetujuan, menyaring bot, dan mengirim webhook bertanda tangan, lalu relay kecil menambahkan subscriber lewat API alat email Anda. Alat email kemudian mengirim email konfirmasi, newsletter, dan tautan unsubscribe, jadi berganti alat nanti berarti mengganti relay, bukan setiap formulir.
Setiap alat email memberi Anda embed: blok markup yang dihasilkan untuk ditempel di halaman Anda. Embed itu berfungsi, tetapi formulirnya kini milik alat tersebut. Formulir mengirim langsung ke penyedia itu, membawa markup penyedia dan sering juga gaya serta script-nya, dan saat Anda pindah ke alat lain Anda harus memburu setiap salinannya di seluruh situs. Panduan ini membangun formulir pendaftaran milik Anda sendiri, dengan markup dan framework yang sudah Anda pakai, dan menghubungkannya ke alat apa pun yang mengirim newsletter Anda.
Perjalanan sebuah pendaftaran
Ada dua tugas di sini, dan keduanya berada di tempat berbeda. Mengumpulkan pendaftaran adalah tugas formulir: memvalidasi alamat, memeriksa kotak persetujuan, menahan bot, mencatat kapan terjadinya. Mengirim newsletter adalah tugas email: mengonfirmasi alamat, mengirim edisi, menangani unsubscribe dan bounce. Ikiform melakukan yang pertama. Alat email Anda melakukan yang kedua. Webhook bertanda tangan menghubungkan keduanya.
- Pembaca mengisi formulir pendaftaran di halaman Anda, dan formulir mengirim ke endpoint Ikiform Anda.
- Ikiform memeriksa alamat, checkbox persetujuan yang wajib, honeypot, skor spam, dan rate limit.
- Pendaftaran yang diterima disimpan di dashboard bersama timestamp, origin, dan referrer-nya.
- Ikiform bisa mengirim email kepada Anda tentang setiap pendaftaran. Ikiform tidak pernah mengirim email ke pembaca.
- Ikiform mengirim webhook submission.created yang bertanda tangan.
- Relay memverifikasi tanda tangan dan menambahkan pembaca ke alat email Anda, yang mengirim konfirmasi dan, nantinya, setiap edisi.
Empat cara mengumpulkan pendaftaran newsletter
| Pendekatan | Unggul dalam | Kekurangan |
|---|---|---|
| Embed dari alat email, dari Mailchimp, Kit, MailerLite, atau beehiiv | Tidak ada yang perlu dijalankan. Konfirmasi, tagging, dan email sambutan sudah diatur di pengaturan alat itu sendiri. | Markup dan perilakunya milik alat itu. Mailchimp mencatat bahwa formulir embed-nya mewarisi gaya situs Anda, jadi bisa terlihat berbeda di halaman Anda, dan mengaktifkan reCAPTCHA-nya untuk formulir yang sudah ada berarti membuat dan menempel kode baru. Setiap salinan terikat pada satu penyedia. |
| Server route Anda sendiri yang memanggil API alat itu | Kendali penuh atas formulir dan request-nya, dengan API key disimpan di server Anda. | Anda membangun sendiri validasi, filter bot, rate limit, dan catatan persetujuan, dan Anda butuh server meskipun situsnya statis. |
| Form builder yang di-host plus otomasi, seperti Typeform dengan Zapier | Tanpa kode, dan alat otomasi mengenal setiap penyedia email. | Iframe yang tidak serasi dengan halaman Anda, plus dua langganan. Webhook dan branding bergantung pada paket. |
| Backend formulir, seperti Ikiform, plus relay | Markup Anda sendiri di situs apa pun, filter spam, catatan persetujuan yang tersimpan, dan webhook bertanda tangan, tanpa server untuk formulirnya. | Ikiform tidak mengirim email ke pembaca. Konfirmasi dan setiap newsletter datang dari alat email Anda, lewat relay kecil atau otomasi. |
Puas dengan satu situs, satu alat, dan embed-nya? Pertahankan. Buat sendiri saat formulir harus sesuai desain Anda, tersebar di beberapa situs, atau mungkin bertahan lebih lama dari alatnya.
Formulirnya
Formulir newsletter yang baik itu kecil: email, nama depan opsional untuk sapaan, dan checkbox persetujuan yang labelnya adalah janji yang Anda buat. Input tersembunyi source mencatat di bagian situs mana pendaftaran terjadi. Pilih stack Anda; pilihan itu berlaku untuk setiap contoh kode di panduan ini.
Tempel formulir di footer, sidebar, atau akhir artikel Anda. Formulir mengirim dengan cara biasa dan mendarat di halaman terima kasih Anda sendiri, jadi bekerja tanpa 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>Agar pembaca tetap di artikel, tambahkan script ini setelah formulir. Script mengirim dengan fetch, mengganti formulir dengan baris yang meminta mereka memeriksa inbox, dan menampilkan pesan server untuk email atau 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;
});Server action meneruskan pendaftaran dengan secret key Anda. Action mengembalikan error per field, jadi pesan email ada di bawah input email dan pesan persetujuan di bawah checkbox. Komponen menerima prop source, jadi footer dan kotak di dalam artikel mencatat nilai yang berbeda.
"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>
);
}Ingin lebih jauh dengan Next.js? Baca Buat formulir pendaftaran newsletter di Next.js.
Astro Action dengan accept: "form" mem-parsing checkbox sebagai boolean dan email dengan z.email(). isInputError membedakan alamat yang buruk dari kiriman yang ditolak. Form action butuh halaman on demand, jadi tambahkan adapter dan atur export const prerender = false di setiap halaman yang merender komponen; ekspor itu tidak berpengaruh di dalam komponen.
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>
)}Ingin lebih jauh dengan Astro? Baca Buat formulir pendaftaran newsletter di Astro.
Function POST createServerFn mengubah FormData menjadi JSON di .validator() dan mengembalikan error field sebagai data alih-alih melempar exception, sehingga komponen bisa menaruh setiap pesan di samping input-nya.
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>
);
}Ingin lebih jauh dengan TanStack Start? Baca Buat formulir pendaftaran newsletter di TanStack Start.
Formulir di footer tidak seharusnya berpindah dari halaman yang sedang dibaca seseorang. Taruh action di resource route /newsletter dan kirim ke sana dengan useFetcher, yang mengirim tanpa navigasi dan menjaga hasilnya di halaman saat ini.
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>
);
}Ingin lebih jauh dengan React Router? Baca Buat formulir pendaftaran newsletter di React Router.
Action berada di +page.server.ts, tidak pernah di layout, jadi action ditaruh di halaman /newsletter dan footer mengirim ke /newsletter?/subscribe. Callback use:enhance kustom menangani hasilnya sendiri alih-alih memanggil update(), jadi pembaca tetap di artikel. Deklarasikan IKIFORM_SECRET_KEY di src/env.ts dengan defineEnvVars agar $app/env/private bisa membacanya.
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}Ingin lebih jauh dengan SvelteKit? Baca Buat formulir pendaftaran newsletter di SvelteKit.
Server route menjauhkan secret key dari browser dan meneruskan error field dari Ikiform di data milik error. Atur NUXT_IKIFORM_SECRET_KEY, dan deklarasikan ikiformSecretKey di bawah runtimeConfig di 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>Ingin lebih jauh dengan Nuxt? Baca Buat formulir pendaftaran newsletter di Nuxt.
Single-page app mengirim dari browser. submit() dari @ikiform/sdk mengirim FormData dan selalu resolve dengan ok dan, saat gagal, error per field, jadi tidak ada blok try yang perlu ditulis. Jangan pernah menaruh secret key di sini; Allowed domains yang melindungi formulir sebagai gantinya.
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>
);
}Panggilan submit() yang sama dalam single-file component Vue. Formulir tetap punya action, jadi tetap mengirim jika script gagal dimuat.
<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>- Ganti
frm_xxxxxxxxxxxxxxxxdengan ID formulir Anda dari dashboard. - Pertahankan name input email sebagai
email. Ikiform memvalidasinya, mengubah domainnya menjadi huruf kecil, dan memeriksanya terhadap domain email sekali pakai. - Input tersembunyi
_gotchaadalah honeypot. Orang tidak pernah melihatnya, dan bot yang mengisi setiap input menandai dirinya sendiri sebagai spam. - Checkbox mengirim
truesaat dicentang. Jika tidak dicentang, checkbox tidak mengirim apa pun, dan checkbox wajib ditolak denganThis box must be checked. - Baris sukses meminta pembaca memeriksa inbox, karena alat email Anda mengirim konfirmasi. Jika Anda mematikan double opt-in, ubah menjadi ucapan terima kasih biasa.
- Versi sisi server meneruskan header
Origindari browser, karena Ikiform mengenali ujilocalhostgratis dari header itu danfetchmilik server tidak mengirimnya.
IKIFORM_SECRET_KEY di environment variable khusus server.Penyiapan framework ada di dokumentasi: Next.js, Astro, SvelteKit, Nuxt, React, Vue, dan HTML biasa. Template formulir newsletter menghasilkan field yang sama sebagai HTML, React, dan server action Next.js.
Field dan definisinya
Tempel definisi di bawah di tab Fields formulir dan Ikiform menegakkannya di server, apa pun yang dikirim halaman. Preview menjalankan validator yang sama di browser Anda: biarkan kotaknya tidak dicentang lalu kirim untuk melihat error yang akan didapat bot atau halaman yang rusak.
Pratinjau
Menjalankan validator Ikiform di browser Anda. Tidak ada yang dikirim.
source adalah input tersembunyi. Di sini ia berupa field teks yang terlihat hanya agar Anda bisa mencobanya.| Field | Alasan | Perhatikan |
|---|---|---|
email | Subscriber | Wajib. type="email" memunculkan keyboard ponsel yang tepat |
first_name | Sapaan di setiap edisi | Opsional. Sebagian besar alat email punya field nama depan untuk dipetakan |
consent | Catatan bahwa orang itu meminta email | Checkbox wajib. Disimpan sebagai true |
source | Formulir mana di situs yang berhasil: footer, artikel, popup | Tersembunyi. Atur nilai berbeda per penempatan, dan teruskan sebagai tag atau field |
Setelah field didefinisikan, Ikiform menolak input apa pun yang tidak dikenalnya dengan unknown_field, jadi tambahkan field ke definisi sebelum menambahkan input ke halaman. Nama yang diawali _, seperti _redirect, adalah instruksi untuk Ikiform dan tidak pernah dihitung sebagai field.
Mencatat persetujuan
Newsletter adalah email yang diminta seseorang. Checkbox membuat permintaan itu eksplisit, dan kiriman yang tersimpan mencatatnya. Inilah yang disimpan Ikiform untuk setiap pendaftaran yang diterima:
{
"email": "ada@example.com",
"first_name": "Ada",
"consent": true,
"source": "footer"
}Di samping field, setiap kiriman punya timestamp created_at, origin dan referrer halaman, negara, dan user agent browser. Bersama-sama, semua itu menunjukkan kapan dan di mana orang itu mencentang kotaknya. Dua kebiasaan membuat catatan itu berguna nantinya:
- Tulis labelnya sebagai janji. "Kirimi saya newsletter lewat email. Saya bisa berhenti berlangganan kapan saja." menyatakan apa yang mereka dapat. Jika Anda juga mengirim penawaran, sebutkan di label.
- Catat tanggalnya saat kata-katanya berubah.
trueyang tersimpan menyatakan orang itu setuju, bukan teks mana yang disetujui. Simpan changelog singkat label beserta tanggalnya, agar setiap pendaftaran bisa dicocokkan dengan kata-kata yang dilihatnya.
Ikiform menyimpan catatannya; Ikiform tidak bisa memberi tahu Anda apa yang diwajibkan aturan di tempat Anda dan pembaca Anda tinggal. Periksa hal itu dengan orang yang memahaminya.
Email konfirmasi datang dari alat email Anda
Ikiform tidak mengirim email ke orang yang mengisi formulir. Email notifikasi hanya dikirim ke Anda dan tim Anda, jadi tidak ada autoresponder, email konfirmasi, atau double opt-in. Kirim semua itu dari alat email Anda, dipicu oleh webhook. Pembagian itu disengaja. Alat yang mengirim newsletter Anda sudah melacak siapa yang mengonfirmasi, siapa yang berhenti berlangganan, dan alamat mana yang bounce. Jika sistem kedua mengirim konfirmasi, catatan-catatan itu akan berbeda satu sama lain.
Jadi relay menambahkan pembaca dalam status yang membuat alat email mengirim konfirmasinya sendiri, dan alat itu menandai mereka aktif saat mereka mengklik. Setiap alat mengaktifkan ini dengan cara berbeda: pengaturan formulir, toggle akun, atau flag di request API. Panduan double opt-in membahas ketiganya, dan apa yang terjadi saat subscriber yang sudah dikenal mendaftar lagi.
Relay
Relay adalah fungsi pendek yang menerima webhook Ikiform, memeriksa tanda tangannya, dan memanggil API alat email Anda. Relay butuh URL HTTPS publik dan satu secret di masing-masing sisi. Yang ini adalah route handler Next.js; kode yang sama berjalan di runtime apa pun yang punya 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 });
}verifyWebhookmemeriksa tanda tangan HMAC dan timestamp. Tanpanya, siapa pun yang menemukan URL bisa menambahkan alamat ke daftar Anda.- Event
endpoint.test, dari Send test di dashboard, dijawab204dan dilewati. - Pemeriksaan persetujuan adalah kunci kedua di belakang definisi formulir.
addSubscriberadalah satu-satunya bagian yang bergantung pada alat. Isinya untuk setiap alat ada di halaman Kit, MailerLite, dan beehiiv.
Jawaban relay menentukan apa yang dilakukan Ikiform selanjutnya:
- Alat email menambahkan atau memperbarui subscriber: relay menjawab 204 dan pengiriman ditandai berhasil.
- Alat email terkena rate limit: relay menjawab 503 dengan Retry-After, dan Ikiform menunggu selama itu sebelum percobaan berikutnya.
- Alat email menolak request atau sedang down: relay menjawab 502, dan Ikiform mencoba ulang sesuai jadwalnya dengan webhook-id yang sama.
Tambahkan URL relay di Webhooks di dashboard, pilih formulir newsletter, dan salin secret whsec_ ke IKIFORM_WEBHOOK_SECRET. Satu workspace punya hingga 3 endpoint. URL harus HTTPS publik di port 443; hostname workers.dev dan pages.dev ditolak, jadi beri Worker domain kustom. Percobaan ulang, payload, dan skema tanda tangan ada di dokumentasi webhook, dan kiriman formulir ke server Anda sendiri punya versi yang menyimpan setiap pengiriman sekali saja.
Tahan bot dan list bombing
Pendaftaran bot lebih merugikan newsletter daripada kebanyakan formulir. Alamat asli yang didaftarkan bot menerima email yang tidak pernah dimintanya, dan keluhan dari orang-orang itu merusak domain pengirim Anda. Sebagian bot mendaftarkan alamat satu korban ke ratusan formulir sekaligus untuk membanjiri inbox-nya, dan setiap formulir yang mengirim email konfirmasi ikut ambil bagian.
| Perlindungan | Yang dihentikan | Setup |
|---|---|---|
| Honeypot | Bot yang mengisi setiap input | Input tersembunyi _gotcha |
| Persetujuan wajib | Script yang hanya mengirim email | Checkbox consent dengan required: true di definisi |
| Skor spam | Alamat sekali pakai (+2), pengulangan identik (+3), tautan dan frasa spam. Pada skor 5, pendaftaran disimpan sebagai spam | Tidak ada |
| Rate limit | Satu pengunjung mengirim terus-menerus | Aktif secara default: Standard, 5 per menit per pengunjung |
| Pemeriksaan manusia | Bot yang menjalankan browser sungguhan | Atur Spam check ke Ikiform untuk Cloudflare Turnstile tanpa key yang perlu dikelola |
| Domain yang diizinkan | Endpoint formulir Anda disalin ke situs lain | Daftarkan setiap domain tempat formulir muncul |
Spam mendapat jawaban sukses yang sama dengan pendaftaran asli dan tidak mengirim webhook, jadi tidak pernah sampai ke alat email Anda dan tidak pernah memicu email konfirmasi. Jika pembaca asli masuk ke spam, tandai sebagai bukan spam dan webhook-nya dikirim saat itu. Dokumentasi spam menjelaskan setiap pemeriksaan dan skornya.
Satu formulir di banyak halaman
Satu endpoint bisa ada di footer setiap halaman, di akhir setiap artikel, dan di halaman /newsletter. Beri setiap penempatan nilai source sendiri untuk mengetahui mana yang menghasilkan pendaftaran:
footer: terlihat di setiap halaman. Cukup email dan checkbox.post: pembaca yang menyelesaikan artikel, sering kali paling tinggi konversinya.newsletter-page: tujuan tautan dari profil media sosial. Ada ruang untuk contoh edisi.
Setiap kiriman juga mencatat referrer, yaitu halaman persis tempat pembaca berada, jadi source memberi tahu penempatannya dan referrer memberi tahu artikelnya. Tambahkan setiap domain yang menampilkan formulir di Allowed domains, hingga 20 per formulir; subdomain dari domain yang terdaftar diizinkan otomatis.
Pembaca yang sama dua kali
Ikiform tidak menggabungkan atau menolak kiriman berulang berdasarkan email. Pengulangan identik dalam 24 jam hanya menambah skor spam. Lakukan deduplikasi di tempat daftar itu disimpan, atau saat Anda mengekspor. Untuk newsletter, hal ini jarang jadi masalah: Kit, MailerLite, dan beehiiv masing-masing mengunci subscriber berdasarkan email, jadi pengulangan memperbarui satu kontak alih-alih membuat kontak kedua.
Berganti alat email nanti
Di sinilah memiliki formulir sendiri terbayar. Pindah dari satu alat email ke alat lain biasanya berarti mengganti setiap embed di situs. Di sini, formulir tidak tahu alat mana yang ada di belakangnya:
- Dengan embed, setiap halaman membawa kode yang mengirim langsung ke alat lama, jadi pindah berarti mengganti setiap salinannya.
- Dengan formulir Anda sendiri, setiap halaman tetap mengirim ke Ikiform, dan Ikiform tetap mengirim webhook yang sama.
- Hanya fungsi addSubscriber milik relay yang berubah, untuk memanggil API alat yang baru.
Pindahkan subscriber yang sudah ada dengan fitur ekspor milik alat lama, karena hanya alat itu yang tahu siapa yang sudah mengonfirmasi dan siapa yang sudah pergi. Ekspor CSV Ikiform, hingga 100.000 baris per file, adalah untuk memeriksa angka, bukan untuk impor.
Pilih situasi Anda
Formulir di atas sama di mana pun. Halaman-halaman ini membahas email konfirmasi, situs tanpa server, dan panggilan API persis untuk tiga alat email.
- Double opt-inIkiform tidak mengirim email konfirmasi, jadi double opt-in berjalan di alat email Anda: relay menambahkan setiap pendaftaran sebagai belum terkonfirmasi dan alat itu mengirim tautannya.
- Situs statisTambahkan formulir pendaftaran newsletter ke situs statis Hugo, Jekyll, Eleventy, atau Astro, jauhkan API key alat email dari halaman, dan lakukan konfirmasi di alat itu.
- Kit (ConvertKit)Hubungkan formulir newsletter Anda sendiri ke Kit dengan relay yang membuat subscriber, menambahkannya ke formulir Kit untuk incentive email, dan memberi tag penempatannya.
- MailerLiteHubungkan formulir pendaftaran Anda sendiri ke MailerLite: relay menambahkan pembaca baru sebagai belum terkonfirmasi untuk double opt-in dan tidak pernah meminta subscriber mengonfirmasi dua kali.
- beehiivHubungkan formulir pendaftaran Anda sendiri ke beehiiv: relay membuat setiap subscription dengan double opt-in aktif, penempatan sebagai UTM source, dan halaman sebagai referrer.
- Formulir newsletter Next.jsFormulir newsletter di footer Next.js: server action dengan pemeriksaan persetujuan dan Turnstile, mengirim ke Ikiform, plus route handler yang menambahkan pembaca ke Kit.
- Formulir newsletter AstroFormulir newsletter Astro yang tetap di halaman prerender: Astro Action memeriksa persetujuan, mengirim ke Ikiform, dan endpoint menambahkan pembaca ke MailerLite.
- Formulir newsletter TanStack StartFormulir newsletter TanStack Start: server function untuk pendaftaran di dalam halaman, server route sebagai cadangan tanpa JavaScript, dan pendaftaran asli dikirim ke beehiiv.
- Formulir newsletter React RouterFormulir newsletter di footer React Router: useFetcher mengirim ke route /newsletter yang juga bekerja tanpa JavaScript, dan resource route mengisi MailerLite.
- Formulir newsletter SvelteKitFormulir newsletter SvelteKit 3 di layout: form action dengan persetujuan dan batas per IP, use:enhance yang tetap di halaman, dan route webhook Kit.
- Formulir newsletter NuxtFormulir newsletter Nuxt yang bekerja dengan atau tanpa JavaScript: server route dengan pemeriksaan Turnstile mengirim ke Ikiform, dan route Nitro mengisi beehiiv.
Pastikan berfungsi
Periksa daftar ini sebelum menambahkan formulir ke setiap halaman. Ikiform tidak punya paket gratis. Pengujian gratis: workspace tanpa paket menerima 50 kiriman uji per bulan dari localhost. Untuk go live, pilih Pro mulai $6/bln untuk 500 kiriman, atau bayar sekali: 10.000 kiriman seharga $49 atau 50.000 kiriman seharga $149, dengan kredit yang tidak pernah kedaluwarsa. Lihat harga.
- Di Allowed domains formulir, tambahkan domain situs Anda, ditambah
localhostselama pengembangan. Biarkan kosong untuk menerima kiriman dari mana saja. - Kirim formulir dengan kotak tidak dicentang dari developer tools browser, atau hapus
requiredsejenak. Anda mendapat422denganThis box must be checkeddan tidak ada yang disimpan. - Kirim dengan benar memakai alamat milik Anda. Anda melihat baris periksa-inbox, atau mendarat di halaman terima kasih Anda.
- Buka Submissions di dashboard. Kiriman baru ada di sana dengan setiap field yang Anda kirim.
- Buka endpoint di Webhooks. Pengiriman menampilkan
succeededdan jawaban relay Anda. - Buka alat email Anda. Subscriber ada di sana, belum terkonfirmasi jika double opt-in aktif, dan email konfirmasi ada di inbox Anda.
- Kirim dengan honeypot terisi,
_gotcha=bot. Kiriman tetap mendapat jawaban sukses, tetapi disimpan sebagai spam dan tidak memicu email atau webhook. - Tidak ada yang masuk? Ubah filter status di dashboard ke Spam. Spam mendapat jawaban sukses yang sama dengan kiriman asli.
Pemecahan masalah
| Masalah | Yang harus dilakukan |
|---|---|
422 validation_failed dengan consent | Checkbox tidak dicentang, atau name input-nya bukan consent. Versi sisi server harus mengirim true, "true", atau "on"; kotak yang tidak dicentang tidak mengirim apa pun. |
422 validation_failed dengan unknown_field | Halaman mengirim input yang tidak tercantum di definisi, sering kali source tersembunyi atau input pelacakan yang ditambahkan belakangan. Tambahkan ke definisi, atau hapus inputnya. |
403 origin_not_allowed, atau setiap kiriman masuk ke spam | Domain halaman tidak ada di Allowed domains formulir. Tambahkan, ditambah localhost untuk uji lokal. Subdomain dari domain yang terdaftar diizinkan. |
429 rate_limited | Satu pengunjung mengirim melebihi rate limit formulir. Standard mengizinkan 5 per menit per pengunjung. Tunggu sesuai Retry-After, atau pilih Relaxed atau Custom untuk formulir yang mengirim dari satu IP bersama. |
| Kiriman ada di dashboard tetapi tidak ada email atau webhook yang datang | Kiriman itu disimpan sebagai spam. Spam tidak mengirim apa pun. Buka di filter Spam untuk melihat alasannya, dan tandai sebagai bukan spam untuk mengirim email dan webhook-nya. |
| Pendaftaran masuk ke Ikiform tetapi tidak ke tool email | Baca log pengiriman di Webhooks. 400 berarti secret relay salah. 502 membawa status dari alat email: biasanya API key, ID daftar, atau ID formulir yang salah. |
| Subscriber ada di alat email tetapi tidak mendapat email konfirmasi | Double opt-in mati di alat itu, atau relay membuat subscriber dalam status sudah terkonfirmasi. Lihat double opt-in untuk tombol pengaturan di setiap alat. |
| Browser mendarat di halaman terima kasih Ikiform, bukan halaman Anda | _redirect diabaikan kecuali domainnya ada di Allowed domains dan berupa URL https:. Tambahkan domainnya, atau atur Redirect after submit di pengaturan formulir sebagai gantinya. |
Pertanyaan
Bisa. Tulis formulir dengan HTML Anda sendiri, kirim ke backend formulir seperti Ikiform, dan teruskan setiap pendaftaran lewat webhook ke Mailchimp atau alat email lain melalui API-nya. Formulir kemudian terlihat seperti bagian lain situs Anda dan tidak berubah saat alat emailnya berganti.
Tidak. Ikiform mengumpulkan, memvalidasi, dan menyimpan pendaftaran lalu meneruskannya lewat webhook. Ikiform tidak pernah mengirim email ke subscriber, jadi email konfirmasi, setiap edisi, dan tautan unsubscribe datang dari alat email Anda.
Alat apa pun yang punya API untuk menambahkan subscriber, atau alat apa pun yang didukung layanan otomasi seperti Zapier, Make, atau n8n. Tidak ada integrasi sekali klik; relay atau otomasi yang melakukan penghubungan. Kit, MailerLite, dan beehiiv punya halaman langkah demi langkah di panduan ini.
Di environment variable sisi server milik relay, jangan pernah di halaman. API key alat email biasanya bisa membaca dan mengubah seluruh daftar, jadi key di JavaScript browser adalah key yang bisa disalin siapa pun.
Pertahankan honeypot _gotcha yang tersembunyi dan checkbox persetujuan yang wajib, daftarkan domain Anda di Allowed domains, dan atur spam check ke Ikiform untuk pemeriksaan Cloudflare Turnstile. Pendaftaran yang disimpan sebagai spam tidak pernah mengirim webhook, jadi tidak pernah memicu email konfirmasi.
Tidak. Setiap kiriman yang diterima disimpan. Kiriman identik yang berulang dalam 24 jam menambah skor spam tetapi tidak otomatis dianggap spam, jadi lakukan deduplikasi berdasarkan email di daftar, database, atau ekspor CSV Anda.
Tidak, tidak ada paket gratis. Pengujian gratis: Anda bisa mengirim 50 kiriman uji per bulan dari localhost sebelum membayar. Untuk go live, Pro mulai $6/bln untuk 500 kiriman, atau Anda bisa bayar sekali untuk 10.000 kiriman seharga $49 atau 50.000 kiriman seharga $149, dengan kredit yang tidak pernah kedaluwarsa.
Mentok di salah satu langkah, atau ingin panduan untuk situasi Anda? Email hello@ikiform.com.