Guias18 min de leitura
Formulário de newsletter sem o embed do Mailchimp
Crie um formulário de newsletter no seu próprio markup, registre o consentimento, barre inscrições de bots e passe cada assinante para Kit, MailerLite, beehiiv ou qualquer ferramenta.
Escreva o formulário de inscrição no seu próprio HTML e aponte-o para um backend de formulários em vez de colar o código de embed do seu provedor de e-mail. O backend valida o endereço e a caixa de consentimento, filtra bots e envia um webhook assinado, e um pequeno relay adiciona o assinante pela API da sua ferramenta de e-mail. A ferramenta de e-mail então envia o e-mail de confirmação, a newsletter e os links de descadastro, então trocar de ferramenta depois significa mudar o relay, não cada formulário.
Toda ferramenta de e-mail oferece um embed: um bloco de markup gerado que você cola na sua página. Funciona, mas o formulário passa a pertencer à ferramenta. Ele envia direto para aquele provedor, carrega o markup do provedor e muitas vezes os estilos e scripts dele, e no dia em que você muda de ferramenta precisa caçar cada cópia dele pelo site. Este guia monta um formulário de inscrição que é seu, no markup e no framework que você já usa, e o conecta à ferramenta que envia a sua newsletter.
Como a inscrição viaja
Há dois trabalhos aqui, e eles ficam em lugares diferentes. Coletar uma inscrição é trabalho de formulário: validar o endereço, conferir a caixa de consentimento, manter os bots fora, registrar quando aconteceu. Enviar uma newsletter é trabalho de e-mail: confirmar o endereço, enviar as edições, cuidar de descadastros e bounces. O Ikiform faz o primeiro. Sua ferramenta de e-mail faz o segundo. Um webhook assinado liga os dois.
- Um leitor preenche o formulário de inscrição na sua página, e ele envia ao seu endpoint do Ikiform.
- O Ikiform confere o endereço, a caixa de consentimento obrigatória, o honeypot, a pontuação de spam e o limite de taxa.
- A inscrição aceita é armazenada no painel com data e hora, origem e referrer.
- O Ikiform pode avisar você por e-mail a cada inscrição. Ele nunca envia e-mail ao leitor.
- O Ikiform envia um webhook submission.created assinado.
- Um relay verifica a assinatura e adiciona o leitor à sua ferramenta de e-mail, que envia a confirmação e, depois, cada edição.
Quatro formas de coletar inscrições na newsletter
| Abordagem | Bom em | Contras |
|---|---|---|
| O embed da ferramenta de e-mail, do Mailchimp, Kit, MailerLite ou beehiiv | Nada para rodar. Confirmação, tags e o e-mail de boas-vindas são configurados nas próprias opções da ferramenta. | O markup e o comportamento são da ferramenta. O Mailchimp avisa que seus formulários incorporados herdam os estilos do seu site, então podem ficar diferentes na sua página, e ligar o reCAPTCHA dele num formulário existente significa gerar e colar um código novo. Cada cópia fica presa a um provedor. |
| Sua própria rota no servidor que chama a API da ferramenta | Controle total do formulário e da requisição, com a chave de API guardada no seu servidor. | Você mesmo constrói a validação, o filtro de bots, os limites de taxa e o registro de consentimento, e precisa de um servidor mesmo que o site seja estático. |
| Um criador de formulários hospedado mais uma automação, como Typeform com Zapier | Nenhum código, e a ferramenta de automação conhece todo provedor de e-mail. | Um iframe que não combina com a sua página, mais duas assinaturas. Webhooks e marca dependem do plano. |
| Um backend de formulários, como o Ikiform, mais um relay | Seu próprio markup em qualquer site, filtro de spam, um registro de consentimento armazenado e webhooks assinados, sem servidor para o formulário. | O Ikiform não envia e-mail ao leitor. A confirmação e cada newsletter saem da sua ferramenta de e-mail, por um pequeno relay ou uma automação. |
Está feliz com um site, uma ferramenta e o embed dela? Mantenha. Construa o seu quando o formulário precisar combinar com o seu design, estiver em vários sites ou puder durar mais que a ferramenta.
O formulário
Um bom formulário de newsletter é pequeno: um e-mail, um primeiro nome opcional para a saudação e uma caixa de consentimento cujo rótulo é a promessa que você está fazendo. Um input oculto source registra em que lugar do site a inscrição aconteceu. Escolha sua stack; a escolha vale para todos os exemplos de código destes guias.
Cole o formulário no rodapé, na barra lateral ou no fim de um post. Ele envia do jeito tradicional e cai na sua própria página de agradecimento, então funciona sem 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>Para manter o leitor no artigo, adicione este script depois do formulário. Ele envia com fetch, troca o formulário por uma frase pedindo para conferir a caixa de entrada e mostra a mensagem do servidor para o e-mail ou a caixa de seleção.
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;
});Uma server action encaminha a inscrição com a sua chave secreta. Ela devolve erros por campo, então a mensagem do e-mail fica embaixo do input de e-mail e a do consentimento embaixo da caixa. O componente recebe uma prop source, então o rodapé e uma caixa dentro do artigo registram valores diferentes.
"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>
);
}Quer ir além com Next.js? Leia Crie um formulário de inscrição em newsletter no Next.js.
Uma Astro Action com accept: "form" interpreta a caixa de seleção como booleano e o e-mail com z.email(). isInputError distingue um endereço inválido de um envio recusado. Form actions precisam de uma página sob demanda, então adicione um adapter e defina export const prerender = false em toda página que renderiza o componente; o export não faz nada dentro de um componente.
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>
)}Quer ir além com Astro? Leia Crie um formulário de inscrição em newsletter no Astro.
Uma função POST createServerFn transforma o FormData em JSON em .validator() e devolve os erros de campo como dados em vez de lançar exceção, para que o componente posicione cada mensagem ao lado do seu 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>
);
}Quer ir além com TanStack Start? Leia Crie um formulário de inscrição em newsletter no TanStack Start.
Um formulário no rodapé não deve tirar a pessoa da página que ela está lendo. Coloque a action numa resource route em /newsletter e envie para ela com useFetcher, que envia sem navegação e mantém o resultado na página atual.
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>
);
}Quer ir além com React Router? Leia Crie um formulário de inscrição em newsletter no React Router.
Actions ficam num +page.server.ts, nunca num layout, então a action fica numa página /newsletter e o rodapé envia para /newsletter?/subscribe. O callback personalizado de use:enhance trata o resultado por conta própria em vez de chamar update(), então o leitor continua no artigo. Declare IKIFORM_SECRET_KEY em src/env.ts com defineEnvVars para que $app/env/private consiga lê-la.
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}Quer ir além com SvelteKit? Leia Crie um formulário de inscrição em newsletter no SvelteKit.
Uma server route mantém a chave secreta longe do navegador e devolve os erros de campo do Ikiform no data do erro. Defina NUXT_IKIFORM_SECRET_KEY e declare ikiformSecretKey em runtimeConfig no 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>Quer ir além com Nuxt? Leia Crie um formulário de inscrição em newsletter no Nuxt.
Uma single-page app envia pelo navegador. submit() do @ikiform/sdk envia o FormData e sempre resolve com ok e, em caso de falha, os erros de campo, então não há bloco try para escrever. Nunca coloque uma chave secreta aqui; quem protege o formulário é Allowed domains.
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>
);
}A mesma chamada submit() num single-file component do Vue. O formulário mantém o action, então ainda envia se o script não carregar.
<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>- Troque
frm_xxxxxxxxxxxxxxxxpelo id do seu formulário, que está no painel. - Mantenha o name do input de e-mail como
email. O Ikiform o valida, coloca o domínio em minúsculas e o confere contra domínios de e-mail descartáveis. - O input oculto
_gotchaé o honeypot. Pessoas nunca o veem, e bots que preenchem todo input se marcam como spam. - A caixa de seleção envia
truequando marcada. Desmarcada, não envia nada, e uma caixa obrigatória é recusada comThis box must be checked. - A frase de sucesso pede para conferir a caixa de entrada, porque sua ferramenta de e-mail envia uma confirmação. Se você desligou o double opt-in, troque por um simples obrigado.
- As versões no servidor repassam o header
Origindo navegador, porque o Ikiform reconhece por ele os testes gratuitos emlocalhoste ofetchdo próprio servidor não envia nenhum.
IKIFORM_SECRET_KEY em variáveis de ambiente só do servidor.A configuração de cada framework está na documentação: Next.js, Astro, SvelteKit, Nuxt, React, Vue e HTML simples. O modelo de formulário de newsletter gera os mesmos campos em HTML, React e uma server action do Next.js.
Campos e a definição
Cole a definição abaixo na aba Fields do formulário e o Ikiform a aplica no servidor, não importa o que a página envie. A prévia roda o mesmo validador no seu navegador: deixe a caixa desmarcada e envie para ver o erro que um bot ou uma página quebrada receberia.
Prévia
Roda o validador do Ikiform no seu navegador. Nada é enviado.
source é um input oculto. Aqui ele é um campo de texto visível só para você poder testar.| Campo | Por quê | Lembre-se |
|---|---|---|
email | O assinante | Obrigatório. type="email" abre o teclado certo no celular |
first_name | Uma saudação em cada edição | Opcional. A maioria das ferramentas de e-mail tem um campo de primeiro nome para mapeá-lo |
consent | Um registro de que a pessoa pediu os e-mails | Caixa de seleção obrigatória. Armazenada como true |
source | Qual formulário do site funcionou: rodapé, post, popup | Oculto. Use um valor diferente por posição e repasse-o como tag ou campo |
Com os campos definidos, o Ikiform recusa qualquer input que não conhece com unknown_field, então adicione o campo à definição antes de adicionar o input à página. Nomes que começam com _, como _redirect, são instruções para o Ikiform e nunca contam como campos.
Registrando o consentimento
Uma newsletter é um e-mail que alguém pediu. A caixa de seleção deixa o pedido explícito, e o envio armazenado o registra. É isto que o Ikiform guarda para cada inscrição aceita:
{
"email": "ada@example.com",
"first_name": "Ada",
"consent": true,
"source": "footer"
}Além dos campos, cada envio tem o timestamp created_at, a origem e o referrer da página, o país e o user agent do navegador. Juntos, mostram quando e onde a pessoa marcou a caixa. Dois hábitos tornam esse registro útil depois:
- Escreva o rótulo como a promessa. "Quero receber a newsletter por e-mail. Posso cancelar a inscrição a qualquer momento." diz o que a pessoa recebe. Se você também envia ofertas, diga isso no rótulo.
- Anote a data quando o texto mudar. Um
truearmazenado diz que a pessoa concordou, não com qual texto. Mantenha um pequeno histórico do rótulo com datas, para que cada inscrição possa ser ligada às palavras que viu.
O Ikiform guarda o registro; ele não sabe dizer o que as regras do lugar onde você e seus leitores vivem exigem. Confira isso com alguém que as conheça.
Os e-mails de confirmação saem da sua ferramenta de e-mail
O Ikiform não envia e-mail para quem preenche o formulário. Os e-mails de notificação vão só para você e sua equipe, então não há resposta automática, e-mail de confirmação nem double opt-in. Envie esses e-mails pela sua ferramenta de e-mail, disparados por um webhook. Essa divisão é proposital. A ferramenta que envia sua newsletter já registra quem confirmou, quem se descadastrou e quais endereços dão bounce. Se um segundo sistema enviasse as confirmações, esses registros sairiam de sincronia.
Por isso o relay adiciona o leitor num estado que faz a ferramenta de e-mail enviar a própria confirmação, e a ferramenta o marca como ativo quando ele clica. Cada ferramenta liga isso de um jeito: uma opção do formulário, uma chave da conta ou uma flag na requisição da API. O guia de double opt-in cobre os três, e o que acontece quando um assinante conhecido se inscreve de novo.
O relay
Um relay é uma função curta que recebe o webhook do Ikiform, confere a assinatura e chama a API da sua ferramenta de e-mail. Ele precisa de uma URL HTTPS pública e de um segredo de cada lado. Este é um route handler do Next.js; o mesmo código roda em qualquer runtime com 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 });
}verifyWebhookconfere a assinatura HMAC e o timestamp. Sem ele, qualquer um que descubra a URL pode adicionar endereços à sua lista.- Eventos
endpoint.test, do Send test no painel, são respondidos com204e ignorados. - A checagem de consentimento é uma segunda trava atrás da definição do formulário.
addSubscriberé a única parte que depende da ferramenta. O corpo dela para cada ferramenta está nas páginas de Kit, MailerLite e beehiiv.
O que o relay responde decide o que o Ikiform faz em seguida:
- A ferramenta de e-mail adiciona ou atualiza o assinante: o relay responde 204 e a entrega é marcada como bem-sucedida.
- A ferramenta de e-mail está com limite de taxa: o relay responde 503 com Retry-After, e o Ikiform espera esse tempo antes da próxima tentativa.
- A ferramenta de e-mail recusa a requisição ou está fora do ar: o relay responde 502, e o Ikiform tenta de novo no seu cronograma com o mesmo webhook-id.
Adicione a URL do relay em Webhooks no painel, escolha o formulário da newsletter e copie o segredo whsec_ para IKIFORM_WEBHOOK_SECRET. Um workspace tem até 3 endpoints. A URL precisa ser HTTPS pública na porta 443; hostnames workers.dev e pages.dev são recusados, então dê um domínio personalizado ao Worker. Tentativas, payloads e o esquema de assinatura estão na documentação de webhooks, e envios de formulário para o seu próprio servidor tem uma versão que armazena cada entrega uma única vez.
Mantenha bots e list bombing longe
Inscrições de bots prejudicam mais uma newsletter do que a maioria dos formulários. Um endereço real inscrito por um bot recebe e-mails que nunca pediu, e reclamações dessas pessoas prejudicam o domínio de onde você envia. Alguns bots inscrevem o endereço de uma vítima em centenas de formulários de uma vez para soterrar a caixa de entrada dela, e todo formulário com e-mail de confirmação participa disso.
| Defesa | O que ela barra | Configuração |
|---|---|---|
| Honeypot | Bots que preenchem todos os inputs | O input oculto _gotcha |
| Consentimento obrigatório | Scripts que enviam só um e-mail | A caixa de seleção consent com required: true na definição |
| Pontuação de spam | Endereços descartáveis (+2), repetições idênticas (+3), links e frases de spam. Com 5, a inscrição é armazenada como spam | Nenhuma |
| Limites de taxa | Um visitante enviando em loop | Ligado por padrão: Standard, 5 por minuto por visitante |
| Verificação humana | Bots que rodam um navegador de verdade | Defina Spam check como Ikiform para usar o Cloudflare Turnstile sem chaves para gerenciar |
| Domínios permitidos | O endpoint do seu formulário copiado em outros sites | Liste todo domínio em que o formulário aparece |
Spam recebe a mesma resposta de sucesso que uma inscrição real e não envia webhook, então nunca chega à sua ferramenta de e-mail nem dispara um e-mail de confirmação. Se um leitor real cair em spam, marque como não spam e o webhook dele sai nesse momento. A documentação de spam explica cada checagem e sua pontuação.
Um formulário em muitas páginas
Um endpoint pode ficar no rodapé de toda página, no fim de cada post e numa página /newsletter. Dê a cada posição seu próprio valor de source para saber qual traz inscrições:
footer: visto em toda página. Deixe só o e-mail e a caixa de seleção.post: leitores que terminaram um artigo, muitas vezes os que mais convertem.newsletter-page: para onde os perfis sociais apontam. Espaço para uma edição de exemplo.
Cada envio também registra o referrer, que é a página exata em que o leitor estava, então source diz a posição e o referrer diz o artigo. Adicione cada domínio que mostra o formulário em Allowed domains, até 20 por formulário; subdomínios de um domínio listado são permitidos automaticamente.
O mesmo leitor duas vezes
O Ikiform não mescla nem rejeita envios repetidos por e-mail. Uma repetição idêntica dentro de 24 horas só soma à pontuação de spam. Remova duplicados onde a lista mora, ou na exportação. Para uma newsletter isso raramente é problema: Kit, MailerLite e beehiiv identificam os assinantes pelo e-mail, então uma repetição atualiza um contato em vez de criar um segundo.
Trocar de ferramenta de e-mail depois
É aqui que ser dono do formulário compensa. Mudar de uma ferramenta de e-mail para outra normalmente significa trocar cada embed do site. Aqui o formulário não sabe qual ferramenta está por trás dele:
- Com um embed, toda página carrega código que envia direto para a ferramenta antiga, então mudar significa trocar cada cópia.
- Com o seu próprio formulário, toda página continua enviando ao Ikiform, e o Ikiform continua enviando o mesmo webhook.
- Só a função addSubscriber do relay muda, para chamar a API da nova ferramenta.
Migre os assinantes existentes com a exportação da própria ferramenta antiga, já que só ela sabe quem confirmou e quem saiu. A exportação CSV do Ikiform, de até 100.000 linhas por arquivo, serve para conferir os números, não para importar.
Escolha o seu caso
O formulário acima é o mesmo em todo lugar. Estas páginas cobrem e-mails de confirmação, sites sem servidor e as chamadas exatas de API para três ferramentas de e-mail.
- Double opt-inO Ikiform não envia e-mails de confirmação, então o double opt-in roda na sua ferramenta de e-mail: um relay adiciona cada inscrição como não confirmada e a ferramenta envia o link.
- Site estáticoAdicione um formulário de newsletter a um site estático em Hugo, Jekyll, Eleventy ou Astro, mantenha a chave de API da ferramenta de e-mail fora da página e confirme na ferramenta.
- Kit (ConvertKit)Conecte seu próprio formulário de newsletter ao Kit com um relay que cria o assinante, o adiciona a um formulário do Kit para o incentive email e marca a posição com uma tag.
- MailerLiteConecte seu próprio formulário de inscrição ao MailerLite: um relay adiciona novos leitores como não confirmados para o double opt-in e nunca pede a um assinante que confirme duas vezes.
- beehiivConecte seu próprio formulário de inscrição ao beehiiv: um relay cria cada subscription com double opt-in ligado, a posição como UTM source e a página como referrer.
- Formulário de newsletter no Next.jsUm formulário de newsletter no rodapé do Next.js: uma server action com checagem de consentimento e Turnstile, enviando ao Ikiform, e um route handler que adiciona leitores ao Kit.
- Formulário de newsletter no AstroUm formulário de newsletter no Astro que funciona em páginas pré-renderizadas: uma Astro Action confere o consentimento, envia ao Ikiform, e um endpoint adiciona leitores ao MailerLite.
- Formulário de newsletter no TanStack StartUm formulário de newsletter no TanStack Start: uma server function para inscrições na página, uma server route como fallback sem JavaScript, e inscrições reais enviadas ao beehiiv.
- Formulário de newsletter no React RouterUm formulário de newsletter no rodapé com React Router: useFetcher envia a uma rota /newsletter que também funciona sem JavaScript, e uma resource route alimenta o MailerLite.
- Formulário de newsletter no SvelteKitUm formulário de newsletter no layout do SvelteKit 3: uma form action com consentimento e limite por IP, use:enhance que fica na página e uma rota de webhook para o Kit.
- Formulário de newsletter no NuxtUm formulário de newsletter no Nuxt que funciona com ou sem JavaScript: uma server route com verificação Turnstile envia ao Ikiform, e uma rota Nitro alimenta o beehiiv.
Confira se funciona
Repasse isto antes de adicionar o formulário a toda página. O Ikiform não tem plano gratuito. Testar é gratuito: um workspace sem plano aceita 50 envios de teste por mês a partir de localhost. Para entrar no ar, escolha o Pro a partir de US$ 6/mês para 500 envios, ou pague uma vez: 10.000 envios por US$ 49 ou 50.000 envios por US$ 149, com créditos que nunca expiram. Veja preços.
- Em Allowed domains do formulário, adicione o domínio do seu site, mais
localhostenquanto desenvolve. Deixe vazio para aceitar envios de qualquer lugar. - Envie o formulário com a caixa desmarcada pelas ferramentas de desenvolvedor do navegador, ou remova o
requiredpor um momento. Você recebe422comThis box must be checkede nada é armazenado. - Envie direito, com um endereço seu. Você vê a frase pedindo para conferir a caixa de entrada, ou cai na sua página de agradecimento.
- Abra Submissions no painel. O novo envio está lá com todos os campos que você enviou.
- Abra o endpoint em Webhooks. A entrega mostra
succeedede a resposta do seu relay. - Abra sua ferramenta de e-mail. O assinante está lá, não confirmado se o double opt-in estiver ligado, e o e-mail de confirmação está na sua caixa de entrada.
- Envie com o honeypot preenchido,
_gotcha=bot. Ele ainda recebe uma resposta de sucesso, mas o envio é armazenado como spam e não dispara e-mail nem webhook. - Não chegou nada? Mude o filtro de status do painel para Spam. Spam recebe a mesma resposta de sucesso de um envio real.
Solução de problemas
| Problema | O que fazer |
|---|---|
422 validation_failed com consent | A caixa não foi marcada, ou o name do input não é consent. Uma versão no servidor precisa enviar true, "true" ou "on"; uma caixa desmarcada não envia nada. |
422 validation_failed com unknown_field | A página envia um input que a definição não lista, muitas vezes um source oculto ou um input de rastreamento adicionado depois. Adicione-o à definição ou remova o input. |
403 origin_not_allowed, ou todo envio cai no spam | O domínio da página não está em Allowed domains do formulário. Adicione-o, mais localhost para testes locais. Subdomínios de um domínio listado são permitidos. |
429 rate_limited | Um visitante enviou mais do que o limite de taxa do formulário permite. O Standard permite 5 por minuto por visitante. Espere o Retry-After, ou escolha Relaxed ou Custom para formulários que enviam a partir de um único IP compartilhado. |
| O envio está no painel, mas nenhum e-mail ou webhook chegou | Ele foi armazenado como spam. Spam não envia nada. Abra-o no filtro Spam para ver os motivos e marque-o como não spam para enviar o e-mail e os webhooks dele. |
| As inscrições chegam no Ikiform, mas não na ferramenta de e-mail | Leia o log de entregas em Webhooks. 400 significa que o segredo do relay está errado. 502 traz o status da ferramenta de e-mail: geralmente uma chave de API, id de lista ou id de formulário errado. |
| O assinante está na ferramenta de e-mail, mas não recebeu e-mail de confirmação | O double opt-in está desligado na ferramenta, ou o relay criou o assinante como já confirmado. Veja double opt-in para a chave de cada ferramenta. |
| O navegador cai na página de agradecimento do Ikiform em vez da sua | O _redirect é ignorado, a menos que o domínio dele esteja em Allowed domains e ele seja uma URL https:. Adicione o domínio, ou defina Redirect after submit nas configurações do formulário. |
Perguntas
Sim. Escreva o formulário no seu próprio HTML, envie-o a um backend de formulários como o Ikiform e encaminhe cada inscrição por webhook ao Mailchimp ou a qualquer outra ferramenta de e-mail pela API dela. O formulário então tem a cara do resto do seu site e não muda quando a ferramenta de e-mail muda.
Não. O Ikiform coleta, valida e armazena as inscrições e as repassa por webhook. Ele nunca envia e-mail ao assinante, então o e-mail de confirmação, cada edição e o link de descadastro saem da sua ferramenta de e-mail.
Qualquer ferramenta com uma API para adicionar assinantes, ou qualquer ferramenta suportada por um serviço de automação como Zapier, Make ou n8n. Não há integrações de um clique; o relay ou a automação faz a conexão. Kit, MailerLite e beehiiv têm páginas passo a passo neste guia.
Nas variáveis de ambiente do relay, no servidor, nunca na página. A chave de API de uma ferramenta de e-mail geralmente consegue ler e alterar a lista inteira, então uma chave no JavaScript do navegador é uma chave que qualquer um pode copiar.
Mantenha o honeypot oculto _gotcha e a caixa de consentimento obrigatória, liste seus domínios em Allowed domains e defina a verificação de spam como Ikiform para ter uma checagem com Cloudflare Turnstile. Inscrições armazenadas como spam nunca enviam webhook, então nunca disparam um e-mail de confirmação.
Não. Todo envio aceito é armazenado. Uma repetição idêntica em até 24 horas aumenta a pontuação de spam, mas não é spam por si só, então remova os duplicados por e-mail na sua lista, no banco de dados ou na exportação CSV.
Não, não há plano gratuito. Testar é grátis: você pode enviar 50 envios de teste por mês a partir de localhost antes de pagar. Para entrar no ar, o Pro começa em US$ 6/mês para 500 envios, ou você pode pagar uma vez 10.000 envios por US$ 49 ou 50.000 envios por US$ 149, com créditos que nunca expiram.
Travou em algum passo ou quer um guia para o seu caso? Envie um e-mail para hello@ikiform.com.