Newsletter signup form7 min read
Build a newsletter signup form in Nuxt
A Nuxt newsletter form that works with or without JavaScript: a server route with a Turnstile check posts to Ikiform, and a Nitro route feeds beehiiv.
Give the form a real action pointing at a Nuxt server route, and enhance it with $fetch so readers with JavaScript stay on the page. The route checks the consent box and a Turnstile token from @nuxtjs/turnstile, forwards the signup to your form backend with a secret key, and answers JSON or a redirect depending on who asked. A second route turns the backend's signed webhook into a beehiiv subscription.
The main guide's Nuxt tab posts with $fetch and works once Vue has hydrated. This page makes the same form work before that, adds a human check, and sends real signups on to beehiiv. Product setup is in the Nuxt docs guide.
One route, two kinds of caller
A Nuxt server route doesn't know whether a browser form or $fetch called it, so it decides from the Accept header. $fetch from the component asks for JSON and gets JSON or an error with a reason. A plain form post, before hydration or with scripts off, asks for HTML, so the route answers 303 to /newsletter with a result code:
- The form posts to /api/newsletter, through $fetch once hydrated or as a plain form post before that.
- The route checks consent and the Turnstile token, then forwards the signup to Ikiform.
- It reads the Accept header to decide how to answer.
- $fetch asked for JSON: success returns JSON, and a problem throws an error with a reason the component shows.
- A plain form post gets a 303 redirect to /newsletter with a result code, and the page turns the code into a message.
Project setup
Install the Turnstile module with npx nuxi@latest module add turnstile, then:
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_keyEvery key under runtimeConfig is overridden at runtime by the matching NUXT_ variable, so the config holds empty strings and the host holds the values. Nothing outside runtimeConfig.public reaches the browser. The Turnstile site key is public by design. Create the Ikiform key under API keys with the submissions:write scope and paste the pillar's field definition on the form's Fields tab.
Build it
The 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.");
});readBodyparses both JSON from$fetchand the URL-encoded body of a plain form post. That's why consent is accepted astrueor"true".verifyTurnstileTokencomes from the Turnstile module and readsruntimeConfig.turnstile.secretKey. Tokens are single-use and expire after five minutes.- The
Originline passes the browser's origin on, because Ikiform recognises freelocalhosttests by that header and a server'sfetchsends none. useRuntimeConfig(event)gets the values with runtimeNUXT_overrides applied, which Nuxt recommends in server routes.
IKIFORM_SECRET_KEY in server-only environment variables.The form component
<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>- The form keeps
actionandmethod, so it posts the plain way until@submit.preventtakes over after hydration. <NuxtTurnstile>renders the widget and puts its token intoken. After any error the token is spent, soreset()asks for a new one.- The fields are read from
FormDatabefore the firstawait, whileevent.currentTargetstill points at the form.
The /newsletter page
<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>Turnstile needs JavaScript, so a reader with scripts off gets the check message. If that trade is wrong for your readers, remove the Turnstile lines and rely on the honeypot and the required box.
Forward subscribers to beehiiv
This route sits in server/routes, so it has no /api prefix. It reads the body with readRawBody before anything parses it, because the signature covers the exact bytes, and creates the subscription with double_opt_override: "on" so beehiiv sends the confirmation. The beehiiv guide adds the first name and beehiiv's own rate limit headers.
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);
});Add https://your-site.com/hooks/ikiform under Webhooks, pick the newsletter form and copy the whsec_ secret into NUXT_IKIFORM_WEBHOOK_SECRET.
Honeypot and the human check
The _gotcha input is forwarded and Ikiform stores a filled one as spam, with a success answer and no webhook, so beehiiv never sees it. The secret key skips Ikiform's own Turnstile check, which is why the route runs one with your own widget.
Rendering: hybrid, not generate
nuxt generate writes a static site with no server, so /api/newsletter and /hooks/ikiform don't exist after it. Build with nuxt build and prerender the pages that can be static through routeRules, as the config above does for /blog/**. Prerendered pages keep the form, and its post still reaches the server route.
Deploy
| Host | What to do |
|---|---|
| Vercel | Nitro detects Vercel and builds server routes as functions. Add the NUXT_ variables under Environment Variables. |
| Netlify | Nitro detects Netlify the same way. Add the variables in the site's environment settings. |
| Cloudflare | Build with nuxt build, not nuxt generate, and set the NUXT_ variables as secrets on the project. Use a custom domain for the webhook URL; Ikiform refuses workers.dev and pages.dev. |
Test it on localhost for free
A workspace without a plan accepts 50 test submissions a month from localhost, and the forwarded Origin makes each signup from nuxt dev one of them. Use Cloudflare's published Turnstile testing keys locally so the check always passes. Ikiform won't deliver webhooks to localhost; expose the dev server through a tunnel with an HTTPS URL to test /hooks/ikiform.
Check it works
- Run
nuxt devand subscribe from the footer. The form is replaced with the check-your-inbox line and the URL doesn't change. - Disable JavaScript and subscribe. You land on
/newsletter?result=check, because the widget couldn't run. - With JavaScript on, remove
requiredfrom the checkbox and submit unticked. The consent message appears and nothing is stored. - Open Submissions in the dashboard. The new submission is there with every field you sent.
- If you added a webhook, open the endpoint in Webhooks. The delivery shows
succeededwith your server's answer. - Open beehiiv. The subscription is there, pending until you confirm, and the confirmation email is in your inbox.
Gotchas
| Problem | What to do |
|---|---|
404 on /api/newsletter in production | The site was built with nuxt generate, which has no server. Build with nuxt build and prerender pages through routeRules. |
| The secret key is empty at runtime | The variable's name doesn't match the config key. ikiformSecretKey needs NUXT_IKIFORM_SECRET_KEY. |
| The human check fails on every second try | The token was reused. Call reset() on the widget after each error, as the component does. |
402 plan_required on local tests | The post had no localhost origin. Check the Origin line, and open the site at localhost. |
| The submission is in the dashboard but no email or webhook arrived | It was stored as spam. Spam sends nothing. Open it under the Spam filter to see the reasons, and mark it as not spam to send its email and webhooks. |
Questions
Yes, if it keeps a real action and method. Point it at a server route that redirects for plain form posts and returns JSON for $fetch, as on this page.
No. nuxt generate outputs static files only, so server routes are missing. Use nuxt build with routeRules to prerender the static pages, or post the form from the browser straight to Ikiform.
The server route only sees Ikiform's answer, and spam gets the same success as a real signup. beehiiv should hear about a reader only after the spam check, which is when the webhook fires.
No, there is no free plan. Testing is free: you can send 50 test submissions a month from localhost before you pay. To go live, Pro starts at $6/mo for 500 submissions, or you can pay once for 10,000 submissions for $49 or 50,000 submissions for $149, with credits that never expire.
Stuck on a step, or want a guide for your situation? Email hello@ikiform.com.