Formulário de inscrição em newsletter8 min de leitura
Crie um formulário de inscrição em newsletter no Nuxt
Um 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.
Dê ao formulário um action de verdade apontando para uma server route do Nuxt e aprimore-o com $fetch para que leitores com JavaScript fiquem na página. A rota confere o checkbox de consentimento e um token do Turnstile vindo de @nuxtjs/turnstile, encaminha a inscrição ao seu backend de formulários com uma chave secreta e responde JSON ou um redirecionamento conforme quem pediu. Uma segunda rota transforma o webhook assinado do backend numa assinatura do beehiiv.
A aba Nuxt do guia principal envia com $fetch e funciona depois que o Vue hidrata. Esta página faz o mesmo formulário funcionar antes disso, adiciona uma verificação humana e repassa as inscrições reais ao beehiiv. A configuração do produto está no guia do Nuxt na documentação.
Uma rota, dois tipos de chamador
Uma server route do Nuxt não sabe se foi chamada por um formulário do navegador ou por $fetch, então decide pelo header Accept. O $fetch do componente pede JSON e recebe JSON ou um erro com um motivo. Um envio de formulário simples, antes da hidratação ou com scripts desligados, pede HTML, então a rota responde 303 para /newsletter com um código de resultado:
- O formulário envia a /api/newsletter, por $fetch depois de hidratado ou como envio de formulário simples antes disso.
- A rota confere o consentimento e o token do Turnstile, e então encaminha a inscrição ao Ikiform.
- Ela lê o header Accept para decidir como responder.
- O $fetch pediu JSON: o sucesso devolve JSON, e um problema lança um erro com um motivo que o componente mostra.
- Um envio de formulário simples recebe um redirecionamento 303 para /newsletter com um código de resultado, e a página transforma o código numa mensagem.
Configuração do projeto
Instale o módulo do Turnstile com npx nuxi@latest module add turnstile e depois:
app/
components/NewsletterForm.vue
pages/newsletter.vue
server/
api/newsletter.post.ts
routes/hooks/ikiform.post.ts
nuxt.config.ts
.envexport default defineNuxtConfig({
modules: ["@nuxtjs/turnstile"],
turnstile: { siteKey: "your_turnstile_site_key" },
runtimeConfig: {
ikiformSecretKey: "",
ikiformWebhookSecret: "",
beehiivApiKey: "",
beehiivPublicationId: "",
turnstile: { secretKey: "" },
},
routeRules: {
"/blog/**": { prerender: true },
},
});NUXT_IKIFORM_SECRET_KEY=ik_sk_your_secret_key
NUXT_IKIFORM_WEBHOOK_SECRET=whsec_your_webhook_secret
NUXT_BEEHIIV_API_KEY=your_beehiiv_api_key
NUXT_BEEHIIV_PUBLICATION_ID=pub_00000000-0000-0000-0000-000000000000
NUXT_TURNSTILE_SECRET_KEY=your_turnstile_secret_keyToda chave em runtimeConfig é sobrescrita em runtime pela variável NUXT_ correspondente, então a config guarda strings vazias e o host guarda os valores. Nada fora de runtimeConfig.public chega ao navegador. A site key do Turnstile é pública por natureza. Crie a chave do Ikiform em API keys com o escopo submissions:write e cole a definição de campos do pilar na aba Fields do formulário.
Construa
A server route
interface Signup {
email?: string;
first_name?: string;
consent?: boolean | string;
source?: string;
_gotcha?: string;
"cf-turnstile-response"?: string;
}
type Result = "subscribed" | "consent" | "check" | "email" | "failed";
const PLACEMENTS = ["footer", "post", "newsletter-page"];
export default defineEventHandler(async (event) => {
const body = (await readBody<Signup>(event)) ?? {};
const wantsJson = getRequestHeader(event, "accept")?.includes("application/json") ?? false;
const config = useRuntimeConfig(event);
const finish = (result: Result, status = 200, message = "") => {
if (!wantsJson) return sendRedirect(event, `/newsletter?result=${result}`, 303);
if (result === "subscribed") return { subscribed: true };
throw createError({ status, message, data: { reason: result } });
};
if (body.consent !== true && body.consent !== "true") {
return finish("consent", 422, "Tick the box so we know you want the emails.");
}
const check = await verifyTurnstileToken(String(body["cf-turnstile-response"] ?? ""), event);
if (!check.success) return finish("check", 403, "The human check didn't pass. Try once more.");
const source = PLACEMENTS.includes(String(body.source)) ? String(body.source) : "footer";
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: String(body.email ?? "").trim(),
first_name: String(body.first_name ?? "").trim().slice(0, 80) || undefined,
consent: true,
source,
_gotcha: String(body._gotcha ?? ""),
}),
}).catch(() => null);
const result = await response?.json().catch(() => null);
if (result?.ok) return finish("subscribed");
const emailError = result?.error?.fields?.email?.[0];
if (emailError) return finish("email", 422, emailError);
return finish("failed", response?.status ?? 502, result?.error?.message ?? "We couldn't reach the list. Try again.");
});readBodyinterpreta tanto o JSON do$fetchquanto o corpo URL-encoded de um envio de formulário simples. Por isso o consentimento é aceito comotrueou"true".verifyTurnstileTokenvem do módulo do Turnstile e lêruntimeConfig.turnstile.secretKey. Os tokens são de uso único e expiram depois de cinco minutos.- A linha
Originrepassa a origem do navegador, porque o Ikiform reconhece os testes gratuitos emlocalhostpor esse header e ofetchde um servidor não envia nenhum. useRuntimeConfig(event)obtém os valores com os overridesNUXT_de runtime aplicados, o que o Nuxt recomenda em server routes.
IKIFORM_SECRET_KEY em variáveis de ambiente só do servidor.O componente do formulário
<script setup lang="ts">
const props = withDefaults(defineProps<{ source?: "footer" | "post" | "newsletter-page" }>(), { source: "footer" });
const turnstile = ref<{ reset: () => void }>();
const token = ref("");
const status = ref<"idle" | "sending" | "done">("idle");
const problem = ref<{ reason: string; message: string } | null>(null);
async function subscribe(event: Event) {
const fields = Object.fromEntries(new FormData(event.currentTarget as HTMLFormElement));
status.value = "sending";
problem.value = null;
try {
await $fetch("/api/newsletter", {
method: "POST",
headers: { Accept: "application/json" },
body: { ...fields, consent: fields.consent === "true", "cf-turnstile-response": token.value },
});
status.value = "done";
} catch (failure) {
const data = (failure as { data?: { message?: string; data?: { reason?: string } } }).data;
problem.value = { reason: data?.data?.reason ?? "failed", message: data?.message ?? "Couldn't subscribe. Try again." };
turnstile.value?.reset();
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="/api/newsletter" method="post" @submit.prevent="subscribe">
<label>
Email
<input name="email" type="email" required autocomplete="email" :aria-invalid="problem?.reason === 'email'" />
</label>
<label>
First name (optional)
<input name="first_name" maxlength="80" autocomplete="given-name" />
</label>
<label>
<input name="consent" type="checkbox" value="true" required :aria-invalid="problem?.reason === 'consent'" />
Email me the newsletter. I can unsubscribe at any time.
</label>
<input type="hidden" name="source" :value="props.source" />
<input name="_gotcha" type="text" tabindex="-1" autocomplete="off" hidden />
<NuxtTurnstile ref="turnstile" v-model="token" />
<p v-if="problem" role="alert">{{ problem.message }}</p>
<button :disabled="status === 'sending'">{{ status === "sending" ? "Subscribing" : "Subscribe" }}</button>
</form>
</template>- O formulário mantém
actionemethod, então envia do jeito tradicional até o@submit.preventassumir depois da hidratação. <NuxtTurnstile>renderiza o widget e coloca o token dele emtoken. Depois de qualquer erro o token já foi gasto, entãoreset()pede um novo.- Os campos são lidos do
FormDataantes do primeiroawait, enquantoevent.currentTargetainda aponta para o formulário.
A página /newsletter
<script setup lang="ts">
const MESSAGES: Record<string, string> = {
subscribed: "Almost done. Open the email we sent you and confirm your address.",
consent: "Tick the box so we know you want the emails.",
check: "The human check needs JavaScript. Turn it on and try again.",
email: "Check the email address and try again.",
failed: "We couldn't reach the list. Try again.",
};
const route = useRoute();
const message = computed(() => MESSAGES[String(route.query.result)] ?? "");
</script>
<template>
<main>
<h1>The monthly newsletter</h1>
<p v-if="message" :role="route.query.result === 'subscribed' ? 'status' : 'alert'">{{ message }}</p>
<NewsletterForm v-if="route.query.result !== 'subscribed'" source="newsletter-page" />
</main>
</template>O Turnstile precisa de JavaScript, então um leitor com scripts desligados recebe a mensagem check. Se essa troca não serve para seus leitores, remova as linhas do Turnstile e conte com o honeypot e o checkbox obrigatório.
Encaminhe assinantes ao beehiiv
Esta rota fica em server/routes, então não tem o prefixo /api. Ela lê o corpo com readRawBody antes que qualquer coisa o interprete, porque a assinatura cobre os bytes exatos, e cria a assinatura com double_opt_override: "on" para que o beehiiv envie a confirmação. O guia do beehiiv acrescenta o primeiro nome e os headers de limite de taxa do próprio beehiiv.
import { verifyWebhook, WebhookVerificationError } from "@ikiform/sdk/webhooks";
export default defineEventHandler(async (event) => {
const config = useRuntimeConfig(event);
let delivery;
try {
delivery = await verifyWebhook({
payload: (await readRawBody(event)) ?? "",
headers: event.headers,
secret: config.ikiformWebhookSecret,
});
} catch (error) {
if (error instanceof WebhookVerificationError) {
setResponseStatus(event, 400);
return error.reason;
}
throw error;
}
if (delivery.type !== "submission.created") return sendNoContent(event);
const { email, consent, source } = delivery.data.fields;
if (consent !== true || typeof email !== "string") return "Skipped";
const answer = await fetch(
`https://api.beehiiv.com/v2/publications/${config.beehiivPublicationId}/subscriptions`,
{
method: "POST",
headers: { Authorization: `Bearer ${config.beehiivApiKey}`, "Content-Type": "application/json" },
body: JSON.stringify({
email,
double_opt_override: "on",
reactivate_existing: false,
utm_source: typeof source === "string" ? source : "website",
referring_site: delivery.data.meta.referrer ?? undefined,
}),
},
);
if (answer.status === 429) {
setResponseHeader(event, "retry-after", 60);
setResponseStatus(event, 503);
return "beehiiv rate limit";
}
if (!answer.ok) {
setResponseStatus(event, 502);
return `beehiiv answered ${answer.status}`;
}
return sendNoContent(event);
});Adicione https://your-site.com/hooks/ikiform em Webhooks, escolha o formulário da newsletter e copie o segredo whsec_ para NUXT_IKIFORM_WEBHOOK_SECRET.
Honeypot e a verificação humana
O input _gotcha é repassado, e o Ikiform armazena um preenchido como spam, com resposta de sucesso e sem webhook, então o beehiiv nunca o vê. A chave secreta pula a verificação Turnstile do próprio Ikiform, e é por isso que a rota roda uma com o seu próprio widget.
Renderização: híbrida, não generate
nuxt generate gera um site estático sem servidor, então /api/newsletter e /hooks/ikiform não existem depois dele. Faça o build com nuxt build e pré-renderize as páginas que podem ser estáticas via routeRules, como a config acima faz para /blog/**. As páginas pré-renderizadas mantêm o formulário, e o envio dele continua chegando à server route.
Deploy
| Host | O que fazer |
|---|---|
| Vercel | O Nitro detecta a Vercel e gera as server routes como funções. Adicione as variáveis NUXT_ em Environment Variables. |
| Netlify | O Nitro detecta a Netlify do mesmo jeito. Adicione as variáveis nas configurações de ambiente do site. |
| Cloudflare | Faça o build com nuxt build, não nuxt generate, e defina as variáveis NUXT_ como segredos no projeto. Use um domínio personalizado para a URL do webhook; o Ikiform recusa workers.dev e pages.dev. |
Teste em localhost de graça
Um workspace sem plano aceita 50 envios de teste por mês vindos de localhost, e o Origin repassado faz cada inscrição feita no nuxt dev contar como um deles. Use localmente as chaves de teste do Turnstile publicadas pela Cloudflare para que a verificação sempre passe. O Ikiform não entrega webhooks em localhost; exponha o servidor de desenvolvimento por um túnel com URL HTTPS para testar /hooks/ikiform.
Confira se funciona
- Rode
nuxt deve inscreva-se pelo rodapé. O formulário é substituído pela frase de confira-sua-caixa-de-entrada e a URL não muda. - Desative o JavaScript e inscreva-se. Você cai em
/newsletter?result=check, porque o widget não conseguiu rodar. - Com o JavaScript ligado, remova
requireddo checkbox e envie sem marcar. A mensagem de consentimento aparece e nada é armazenado. - Abra Submissions no painel. O novo envio está lá com todos os campos que você enviou.
- Se você adicionou um webhook, abra o endpoint em Webhooks. A entrega mostra
succeededcom a resposta do seu servidor. - Abra o beehiiv. A assinatura está lá, pendente até você confirmar, e o e-mail de confirmação está na sua caixa de entrada.
Pegadinhas
| Problema | O que fazer |
|---|---|
404 em /api/newsletter em produção | O site foi gerado com nuxt generate, que não tem servidor. Faça o build com nuxt build e pré-renderize as páginas via routeRules. |
| A chave secreta está vazia em runtime | O nome da variável não corresponde à chave da config. ikiformSecretKey precisa de NUXT_IKIFORM_SECRET_KEY. |
| A verificação humana falha a cada duas tentativas | O token foi reutilizado. Chame reset() no widget depois de cada erro, como o componente faz. |
402 plan_required em testes locais | O envio não tinha origem localhost. Confira a linha Origin e abra o site em localhost. |
| 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. |
Perguntas
Sim, se ele mantiver um action e um method de verdade. Aponte-o para uma server route que redireciona envios de formulário simples e devolve JSON para o $fetch, como nesta página.
Não. O nuxt generate gera só arquivos estáticos, então as server routes ficam de fora. Use nuxt build com routeRules para pré-renderizar as páginas estáticas, ou envie o formulário do navegador direto ao Ikiform.
A server route só vê a resposta do Ikiform, e o spam recebe o mesmo sucesso de uma inscrição real. O beehiiv só deve saber de um leitor depois da checagem de spam, que é quando o webhook dispara.
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.