# Buat formulir pendaftaran newsletter di Nuxt

> Formulir newsletter Nuxt yang bekerja dengan atau tanpa JavaScript: server route dengan pemeriksaan Turnstile mengirim ke Ikiform, dan route Nitro mengisi beehiiv.

Terbit 2026-10-25 · https://ikiform.com/id/guides/newsletter-signup/nuxt

Beri formulir `action` sungguhan yang mengarah ke server route Nuxt, dan tingkatkan dengan `$fetch` agar pembaca dengan JavaScript tetap di halaman. Route memeriksa kotak persetujuan dan token Turnstile dari `@nuxtjs/turnstile`, meneruskan pendaftaran ke backend formulir Anda dengan secret key, dan menjawab dengan JSON atau redirect tergantung siapa yang meminta. Route kedua mengubah webhook bertanda tangan dari backend menjadi subscription beehiiv.

[Tab Nuxt di panduan utama](https://ikiform.com/id/guides/newsletter-signup.md#form) mengirim dengan `$fetch` dan berfungsi setelah Vue ter-hydrate. Halaman ini membuat formulir yang sama berfungsi sebelum itu, menambahkan pemeriksaan manusia, dan mengirim pendaftaran asli ke beehiiv. Penyiapan produk ada di [panduan dokumentasi Nuxt](https://ikiform.com/id/docs/guides/nuxt.md).

## Satu route, dua jenis pemanggil

Server route Nuxt tidak tahu apakah formulir browser atau `$fetch` yang memanggilnya, jadi ia memutuskan dari header `Accept`. `$fetch` dari komponen meminta JSON dan mendapat JSON atau error dengan alasannya. Form post biasa, sebelum hydration atau dengan script mati, meminta HTML, jadi route menjawab `303` ke `/newsletter` dengan kode hasil:

Diagram: Bagaimana route newsletter Nuxt menjawab setiap jenis pemanggil

1. Formulir mengirim ke /api/newsletter, lewat $fetch setelah hydrate atau sebagai form post biasa sebelum itu.
2. Route memeriksa persetujuan dan token Turnstile, lalu meneruskan pendaftaran ke Ikiform.
3. Route membaca header Accept untuk menentukan cara menjawab.
4. $fetch meminta JSON: sukses mengembalikan JSON, dan masalah melempar error dengan alasan yang ditampilkan komponen.
5. Form post biasa mendapat redirect 303 ke /newsletter dengan kode hasil, dan halaman mengubah kode itu menjadi pesan.

Redirect membawa kode, tidak pernah pesan, jadi tautan rekayasa tidak bisa menaruh teksnya sendiri di halaman Anda.

## Setup proyek

Instal modul Turnstile dengan `npx nuxi@latest module add turnstile`, lalu:

`Struktur proyek`:

```text
app/
  components/NewsletterForm.vue
  pages/newsletter.vue
server/
  api/newsletter.post.ts
  routes/hooks/ikiform.post.ts
nuxt.config.ts
.env
```

`nuxt.config.ts`:

```ts
export default defineNuxtConfig({
  modules: ["@nuxtjs/turnstile"],
  turnstile: { siteKey: "your_turnstile_site_key" },
  runtimeConfig: {
    ikiformSecretKey: "",
    ikiformWebhookSecret: "",
    beehiivApiKey: "",
    beehiivPublicationId: "",
    turnstile: { secretKey: "" },
  },
  routeRules: {
    "/blog/**": { prerender: true },
  },
});
```

`.env`:

```bash
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_key
```

Setiap key di bawah `runtimeConfig` ditimpa saat runtime oleh variabel `NUXT_` yang cocok, jadi config berisi string kosong dan host menyimpan nilainya. Tidak ada di luar `runtimeConfig.public` yang sampai ke browser. Site key Turnstile memang publik. Buat key Ikiform di **API keys** dengan scope `submissions:write` dan tempel [definisi field dari pilar](https://ikiform.com/id/guides/newsletter-signup.md#fields) di tab **Fields** formulir.

## Bangun

### Server route

`server/api/newsletter.post.ts`:

```ts
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.");
});
```

- `readBody` mem-parsing JSON dari `$fetch` maupun body URL-encoded dari form post biasa. Itulah sebabnya persetujuan diterima sebagai `true` atau `"true"`.
- `verifyTurnstileToken` berasal dari modul Turnstile dan membaca `runtimeConfig.turnstile.secretKey`. Token hanya sekali pakai dan kedaluwarsa setelah lima menit.
- Baris `Origin` meneruskan origin browser, karena Ikiform mengenali uji `localhost` gratis dari header itu dan `fetch` milik server tidak mengirimnya.
- `useRuntimeConfig(event)` mendapatkan nilai dengan override `NUXT_` saat runtime sudah diterapkan, yang disarankan Nuxt di server route.

> Versi sisi server mengirim dengan secret key, sehingga Ikiform melewati **Allowed domains** dan pemeriksaan manusianya. Semua kiriman lalu datang dari IP server Anda, dan batas Standard 5 per menit per pengunjung menghitung semua pengunjung Anda sebagai satu. Pilih [rate limit](https://ikiform.com/id/docs/rate-limits.md) Relaxed atau Custom untuk formulir yang Anda kirim lewat server, dan simpan `IKIFORM_SECRET_KEY` di environment variable khusus server.

### Komponen formulir

`app/components/NewsletterForm.vue`:

```vue
<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>
```

- Formulir mempertahankan `action` dan `method`, jadi formulir mengirim dengan cara biasa sampai `@submit.prevent` mengambil alih setelah hydration.
- `<NuxtTurnstile>` merender widget dan menaruh token-nya di `token`. Setelah error apa pun token sudah terpakai, jadi `reset()` meminta yang baru.
- Field dibaca dari `FormData` sebelum `await` pertama, selagi `event.currentTarget` masih menunjuk ke formulir.

### Halaman /newsletter

`app/pages/newsletter.vue`:

```vue
<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 butuh JavaScript, jadi pembaca dengan script mati mendapat pesan `check`. Jika kompromi itu tidak cocok untuk pembaca Anda, hapus baris Turnstile dan andalkan honeypot serta kotak wajib.

## Teruskan subscriber ke beehiiv

Route ini ada di `server/routes`, jadi tidak punya awalan `/api`. Route membaca body dengan `readRawBody` sebelum ada yang mem-parsing-nya, karena tanda tangan mencakup byte persisnya, dan membuat subscription dengan `double_opt_override: "on"` agar beehiiv mengirim konfirmasi. [Panduan beehiiv](https://ikiform.com/id/guides/newsletter-signup/beehiiv.md) menambahkan nama depan dan header rate limit beehiiv.

`server/routes/hooks/ikiform.post.ts`:

```ts
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);
});
```

Tambahkan `https://your-site.com/hooks/ikiform` di **Webhooks**, pilih formulir newsletter, dan salin secret `whsec_` ke `NUXT_IKIFORM_WEBHOOK_SECRET`.

## Honeypot dan pemeriksaan manusia

Input `_gotcha` diteruskan dan Ikiform menyimpan yang terisi sebagai spam, dengan jawaban sukses dan tanpa webhook, jadi beehiiv tidak pernah melihatnya. Secret key melewati pemeriksaan Turnstile milik Ikiform, itulah sebabnya route menjalankan pemeriksaan dengan widget Anda sendiri.

## Rendering: hybrid, bukan generate

`nuxt generate` menulis situs statis tanpa server, jadi `/api/newsletter` dan `/hooks/ikiform` tidak ada setelahnya. Build dengan `nuxt build` dan lakukan prerender halaman yang bisa statis lewat `routeRules`, seperti yang dilakukan config di atas untuk `/blog/**`. Halaman yang di-prerender tetap punya formulir, dan kirimannya tetap sampai ke server route.

## Deploy

| Host | Yang harus dilakukan |
| --- | --- |
| Vercel | Nitro mendeteksi Vercel dan membangun server route sebagai function. Tambahkan variabel `NUXT_` di **Environment Variables**. |
| Netlify | Nitro mendeteksi Netlify dengan cara yang sama. Tambahkan variabel di pengaturan environment situs. |
| Cloudflare | Build dengan `nuxt build`, bukan `nuxt generate`, dan atur variabel `NUXT_` sebagai secret di proyek. Gunakan domain kustom untuk URL webhook; Ikiform menolak `workers.dev` dan `pages.dev`. |

## Uji gratis di localhost

Workspace tanpa paket menerima 50 kiriman uji per bulan dari `localhost`, dan `Origin` yang diteruskan membuat setiap pendaftaran dari `nuxt dev` dihitung sebagai salah satunya. Gunakan testing key Turnstile yang dipublikasikan Cloudflare secara lokal agar pemeriksaan selalu lolos. Ikiform tidak akan mengirim webhook ke `localhost`; ekspos dev server lewat tunnel dengan URL HTTPS untuk menguji `/hooks/ikiform`.

## Pastikan berfungsi

1. Jalankan `nuxt dev` dan berlangganan dari footer. Formulir diganti dengan baris periksa-inbox dan URL tidak berubah.
2. Matikan JavaScript dan berlangganan. Anda mendarat di `/newsletter?result=check`, karena widget tidak bisa berjalan.
3. Dengan JavaScript aktif, hapus `required` dari checkbox dan kirim tanpa dicentang. Pesan persetujuan muncul dan tidak ada yang disimpan.
4. Buka **Submissions** di dashboard. Kiriman baru ada di sana dengan setiap field yang Anda kirim.
5. Jika Anda menambahkan webhook, buka endpoint-nya di **Webhooks**. Pengiriman menampilkan `succeeded` dengan jawaban dari server Anda.
6. Buka beehiiv. Subscription ada di sana, pending sampai Anda mengonfirmasi, dan email konfirmasi ada di inbox Anda.

## Hal yang sering menjebak

| Masalah | Yang harus dilakukan |
| --- | --- |
| `404` di `/api/newsletter` di produksi | Situs dibangun dengan `nuxt generate`, yang tidak punya server. Build dengan `nuxt build` dan lakukan prerender halaman lewat `routeRules`. |
| Secret key kosong saat runtime | Nama variabel tidak cocok dengan key config. `ikiformSecretKey` butuh `NUXT_IKIFORM_SECRET_KEY`. |
| Pemeriksaan manusia gagal di setiap percobaan kedua | Token dipakai ulang. Panggil `reset()` pada widget setelah setiap error, seperti yang dilakukan komponen. |
| `402 plan_required` pada uji lokal | Kiriman tidak punya origin `localhost`. Periksa baris `Origin`, dan buka situs di `localhost`. |
| 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. |

## Pertanyaan

### Bisakah formulir Nuxt berfungsi tanpa JavaScript?

Bisa, jika formulir mempertahankan action dan method sungguhan. Arahkan ke server route yang me-redirect untuk form post biasa dan mengembalikan JSON untuk $fetch, seperti di halaman ini.

### Apakah nuxt generate mendukung server route untuk formulir newsletter?

Tidak. nuxt generate hanya menghasilkan file statis, jadi server route tidak ada. Gunakan nuxt build dengan routeRules untuk melakukan prerender halaman statis, atau kirim formulir dari browser langsung ke Ikiform.

### Mengapa mengirim subscriber ke beehiiv dari webhook alih-alih dari server route?

Server route hanya melihat jawaban Ikiform, dan spam mendapat sukses yang sama seperti pendaftaran asli. beehiiv sebaiknya baru tahu tentang pembaca setelah pemeriksaan spam, yaitu saat webhook terpicu.

### Apakah Ikiform gratis?

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.

## Panduan lainnya

- [Formulir pendaftaran newsletter](https://ikiform.com/id/guides/newsletter-signup.md): 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.
- [beehiiv](https://ikiform.com/id/guides/newsletter-signup/beehiiv.md): Hubungkan formulir pendaftaran Anda sendiri ke beehiiv: relay membuat setiap subscription dengan double opt-in aktif, penempatan sebagai UTM source, dan halaman sebagai referrer.
- [Double opt-in](https://ikiform.com/id/guides/newsletter-signup/double-opt-in.md): Ikiform 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.
- [Formulir newsletter SvelteKit](https://ikiform.com/id/guides/newsletter-signup/sveltekit.md): Formulir newsletter SvelteKit 3 di layout: form action dengan persetujuan dan batas per IP, use:enhance yang tetap di halaman, dan route webhook Kit.
- [Situs statis](https://ikiform.com/id/guides/newsletter-signup/static-site.md): Tambahkan 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)](https://ikiform.com/id/guides/newsletter-signup/kit.md): Hubungkan formulir newsletter Anda sendiri ke Kit dengan relay yang membuat subscriber, menambahkannya ke formulir Kit untuk incentive email, dan memberi tag penempatannya.
