# Membuat daftar tunggu di Nuxt

> Daftar tunggu Nuxt di landing page yang di-prerender: satu route Nitro menerima $fetch dan kiriman formulir biasa, memeriksanya dengan Zod, dan me-redirect tanpa JavaScript.

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

Prerender landing page dengan `routeRules` dan kirim formulir ke server route Nitro yang memvalidasi email dan peran dengan Zod lalu meneruskannya ke backend formulir Anda dengan secret key dari `runtimeConfig`. Route menjawab JSON untuk `$fetch` dan redirect `303` untuk kiriman formulir biasa, sehingga pendaftaran berfungsi sebelum halaman di-hydrate.

[Panduan utama](https://ikiform.com/id/guides/waitlist.md#form) punya tab Nuxt: server route, `runtimeConfig`, dan komponen yang mengirim dengan `$fetch`. Formulir itu memerlukan JavaScript, hanya mengirim email, dan menyajikan halaman di setiap request. Versi ini melakukan prerender landing page, mengajukan satu pertanyaan pengelompokan, dan tetap berfungsi saat bundle belum dimuat.

## Apa yang berbeda di Nuxt

Nuxt merender di server secara default, dan Nitro menyajikan folder `server/` di samping halaman Anda. Tiga detail menentukan bentuk daftar tunggu:

| Detail | Dampaknya pada daftar tunggu |
| --- | --- |
| `routeRules` dengan `prerender: true` | Landing page dibangun sekali sebagai HTML. Server route tetap berjalan per request |
| `readBody` juga mem-parse body urlencoded | `<form>` biasa bisa mengirim ke route yang sama dengan `$fetch`, begitu pula formulir di situs orang lain |
| Halaman yang di-prerender tidak punya query string saat build | `?ref=` dan `?problem=` harus dibaca setelah halaman di-mount, bukan saat render |

Baris kedua punya dua sisi. Itulah yang membuat jalur tanpa JavaScript berfungsi, dan itulah sebabnya route memeriksa header `Sec-Fetch-Site` sebelum melakukan apa pun: jika tidak, formulir di situs lain bisa mengirim pendaftaran memakai key Anda.

Diagram: Satu route Nitro menjawab $fetch dengan JSON dan kiriman biasa dengan redirect

1. Landing page adalah HTML statis. Setelah di-mount, komponen menyalin ?ref= atau ?utm_source= ke nilai sumbernya.
2. Formulir mengirim ke server route: sebagai JSON lewat $fetch setelah Vue selesai hydrate, atau urlencoded sebagai kiriman biasa sebelum itu.
3. Route menolak kiriman lintas situs, memvalidasi body dengan Zod, dan meneruskan pendaftaran ke Ikiform dengan secret key.
4. Kiriman JSON mendapat { joined: true }, atau error yang pesannya ditampilkan komponen di bawah formulir.
5. Kiriman biasa mendapat 303 ke /thanks, atau kembali ke / dengan kode masalah yang diubah halaman menjadi pesan setelah di-mount.

Content type kiriman menentukan jawabannya. Kedua halaman tempat browser mendarat di-prerender.

## Siapkan proyek

Buat formulir dan secret key seperti di [panduan Nuxt di dokumentasi](https://ikiform.com/id/docs/guides/nuxt.md). File di halaman ini, dalam layout `app/` Nuxt 4:

`Proyek`:

```text
app/
  pages/index.vue
  pages/thanks.vue
  components/WaitlistForm.vue
server/
  api/waitlist.post.ts
nuxt.config.ts
.env
```

`nuxt.config.ts`:

```ts
export default defineNuxtConfig({
  runtimeConfig: {
    ikiformSecretKey: "",
  },
  routeRules: {
    "/": { prerender: true },
    "/thanks": { prerender: true },
  },
});
```

`ikiformSecretKey` yang kosong di `runtimeConfig` diisi saat runtime dari `NUXT_IKIFORM_SECRET_KEY`. Key di luar `public` hanya ada di server. Tambahkan Zod dengan `npm install zod`.

`.env`:

```bash
NUXT_IKIFORM_SECRET_KEY=ik_sk_your_secret_key
```

> Versi sisi server mengirim dengan secret key, jadi Ikiform melewati **Allowed domains** dan pemeriksaan manusianya. Setiap kiriman lalu datang dari IP server Anda, dan batas Standard 5 per menit per pengunjung menghitung semua pengunjung Anda sekaligus. 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.

Formulir ini mengirim `email`, `role`, dan `ref`. Jika Anda menambahkan definisi field di tab **Fields** formulir, cantumkan ketiganya, dengan nilai peran yang sama seperti di select:

`Field`:

```json
{
  "version": 1,
  "title": "Waitlist",
  "fields": [
    { "id": "email", "type": "email", "label": "Email", "required": true, "placeholder": "you@company.com" },
    { "id": "role", "type": "select", "label": "What do you do?", "options": [{ "value": "founder", "label": "Founder" }, { "value": "engineer", "label": "Engineer" }, { "value": "designer", "label": "Designer" }, { "value": "other", "label": "Something else" }] },
    { "id": "ref", "type": "text", "label": "Source", "validation": { "max_length": 40 } }
  ]
}
```

## Bangun

### Server route-nya

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

```ts
import { z } from "zod";

const Signup = z.object({
  email: z.email(),
  role: z.enum(["founder", "engineer", "designer", "other"]).optional().catch(undefined),
  ref: z.string().regex(/^[a-z0-9_-]{1,40}$/).catch("direct"),
  _gotcha: z.string().catch(""),
});

const MESSAGES = {
  email: "Enter the email we should send your invite to.",
  busy: "Lots of people are joining. Try again in a minute.",
  failed: "Couldn't join the list. Try again.",
};

type Problem = keyof typeof MESSAGES;

export default defineEventHandler(async (event) => {
  if (getRequestHeader(event, "sec-fetch-site") === "cross-site") {
    throw createError({ status: 403, message: "Posts from other sites aren't accepted." });
  }
  const plain = !getRequestHeader(event, "content-type")?.includes("application/json");

  const answer = (problem: Problem | null) => {
    if (plain) return sendRedirect(event, problem ? `/?problem=${problem}` : "/thanks", 303);
    if (problem) throw createError({ status: problem === "busy" ? 429 : 400, message: MESSAGES[problem] });
    return { joined: true };
  };

  const parsed = Signup.safeParse(await readBody(event));
  if (!parsed.success) return answer("email");

  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(parsed.data),
    signal: AbortSignal.timeout(8000),
  }).catch(() => null);

  if (!response) return answer("failed");
  if (response.status === 429) return answer("busy");
  const result = await response.json().catch(() => null);
  if (result?.ok) return answer(null);
  return answer(result?.error?.fields?.email ? "email" : "failed");
});
```

- Akhiran `.post.ts` membatasi route hanya untuk `POST`. Metode lain mendapat `405`.
- `Sec-Fetch-Site` bernilai `same-origin` untuk halaman Anda sendiri dan `cross-site` untuk formulir di tempat lain. Browser yang mengaturnya dan skrip halaman tidak bisa mengubahnya.
- `.catch()` Zod mengubah `role` atau `ref` yang salah menjadi nilai aman alih-alih menggagalkan pendaftaran. Hanya email yang salah yang layak ditampilkan sebagai error.
- Header `Origin` browser diteruskan karena Ikiform menghitung kiriman uji gratis dari `localhost` berdasarkan header itu, dan `fetch` milik route tidak mengirimnya.
- `useRuntimeConfig(event)` membaca key per request, jadi nilai yang diatur di host setelah build tetap terbaca.
- Redirect membawa kode masalah singkat, tidak pernah pesannya, jadi tautan yang direkayasa tidak bisa menulis teks ke halaman Anda.

### Komponen formulir

`app/components/WaitlistForm.vue`:

```vue
<script setup lang="ts">
const MESSAGES: Record<string, string> = {
  email: "Enter the email we should send your invite to.",
  busy: "Lots of people are joining. Try again in a minute.",
  failed: "Couldn't join the list. Try again.",
};

const route = useRoute();
const email = ref("");
const role = ref("");
const source = ref("direct");
const trap = ref("");
const status = ref<"idle" | "sending" | "joined">("idle");
const error = ref("");

onMounted(() => {
  const tag = String(route.query.ref ?? route.query.utm_source ?? "").toLowerCase().slice(0, 40);
  if (tag) source.value = tag;
  const problem = String(route.query.problem ?? "");
  if (MESSAGES[problem]) error.value = MESSAGES[problem];
});

async function join() {
  status.value = "sending";
  error.value = "";
  try {
    await $fetch("/api/waitlist", {
      method: "POST",
      body: { email: email.value, role: role.value || undefined, ref: source.value, _gotcha: trap.value },
    });
    status.value = "joined";
  } catch (failure) {
    error.value = (failure as { data?: { message?: string } }).data?.message ?? "Couldn't join the list. Try again.";
    status.value = "idle";
  }
}
</script>

<template>
  <p v-if="status === 'joined'" role="status">You're on the list. We'll email you on launch day.</p>
  <form v-else method="post" action="/api/waitlist" @submit.prevent="join">
    <label for="email">Email</label>
    <input id="email" v-model="email" name="email" type="email" autocomplete="email" required />
    <label for="role">What do you do?</label>
    <select id="role" v-model="role" name="role">
      <option value="">Choose one</option>
      <option value="founder">Founder</option>
      <option value="engineer">Engineer</option>
      <option value="designer">Designer</option>
      <option value="other">Something else</option>
    </select>
    <input type="hidden" name="ref" :value="source" />
    <input v-model="trap" name="_gotcha" type="text" tabindex="-1" autocomplete="off" hidden />
    <p role="alert">{{ error }}</p>
    <button :disabled="status === 'sending'">{{ status === "sending" ? "Joining" : "Join the waitlist" }}</button>
  </form>
</template>
```

- Formulir mempertahankan `method` dan `action`. Sebelum hydration, browser mengirimnya sebagai urlencoded; setelahnya, `@submit.prevent` menyerahkannya ke `$fetch`, yang mengirim JSON.
- `onMounted` membaca query. Membacanya saat render akan memberi HTML hasil build nilai kosong dan browser nilai berbeda, yaitu hydration mismatch.
- Peran kosong dikirim sebagai `undefined`, yang dihilangkan `$fetch` dari body JSON.
- Error `$fetch` membawa pesan route di `data.message`, jadi komponen menampilkan kata-kata dari server.

Gunakan `<WaitlistForm />` di `app/pages/index.vue`, dan tulis `app/pages/thanks.vue` dengan kalimat terima kasih. Honeypot diteruskan tanpa diubah, jadi bot yang mengisinya disimpan sebagai spam. Kiriman dengan secret key melewati pemeriksaan manusia Ikiform, jadi mengaktifkan **Spam check** tidak menambah apa pun di sini.

## Catatan deploy

Build dengan `nuxt build`. `nuxt generate` melakukan prerender setiap halaman sebagai situs statis tanpa server, jadi `/api/waitlist` tidak akan pernah berjalan. `routeRules` di atas memberi Anda halaman statis dan route live dari satu build.

| Host | Setup | Secret-nya |
| --- | --- | --- |
| Vercel | Tanpa konfigurasi. Vercel mendeteksi Nitro saat impor | `NUXT_IKIFORM_SECRET_KEY` di **Environment Variables** |
| Netlify | Tanpa konfigurasi. Nuxt mendeteksi build Netlify | `NUXT_IKIFORM_SECRET_KEY` di **Environment variables** |
| Cloudflare Pages | Tanpa konfigurasi dengan integrasi Git. Untuk upload langsung, build dengan `--preset=cloudflare_pages` | `NUXT_IKIFORM_SECRET_KEY` sebagai variabel terenkripsi di pengaturan proyek |

## Pastikan berfungsi

1. Jalankan `nuxt dev`, buka `http://localhost:3000/?ref=test`, pilih peran, dan mendaftar. Kalimat terima kasih menggantikan formulir.
2. Buka **Submissions** di dashboard. Pendaftaran memiliki `ref` bernilai `test` dan peran yang Anda pilih.
3. Matikan JavaScript dan mendaftar tanpa peran. Anda mendarat di `/thanks`, dan kiriman tidak memiliki `role`.
4. Dengan JavaScript mati, kirim `a@b`. Anda mendarat di `/?problem=email`, dan pesannya muncul setelah Anda menyalakan JavaScript lagi dan reload.
5. Kirim dengan honeypot terisi, `_gotcha=bot`. Kiriman tetap mendapat jawaban sukses, tetapi disimpan sebagai spam dan tidak memicu email atau webhook.
6. Kiriman dari `nuxt dev` membawa origin `localhost`, jadi dihitung sebagai kiriman uji. Ikiform tidak punya paket gratis. Pengujian gratis: workspace tanpa paket menerima 50 kiriman uji per bulan dari `localhost`. Untuk go live, pilih Pro mulai $6/bln untuk 500 kiriman, atau bayar sekali: 10.000 kiriman seharga $49 atau 50.000 kiriman seharga $149, dengan kredit yang tidak pernah kedaluwarsa. Lihat [harga](https://ikiform.com/id/pricing).

## Jebakan

| Masalah | Yang harus dilakukan |
| --- | --- |
| `404` pada `/api/waitlist` di production | Situs dibangun dengan `nuxt generate`, yang tidak menyertakan server. Build dengan `nuxt build` dan pertahankan `prerender` di `routeRules`. |
| Peringatan hydration mismatch pada input tersembunyi `ref` | Query dibaca saat render. Baca di `onMounted`, seperti di atas; HTML yang di-prerender tidak pernah punya query string. |
| Setiap kiriman menjawab `failed` | Key-nya kosong. Variabel harus bernama `NUXT_IKIFORM_SECRET_KEY` agar mengisi `runtimeConfig.ikiformSecretKey`. |
| `422` dengan `unknown_field` untuk `role` atau `ref` | Definisi field tidak mencantumkannya. Tambahkan keduanya, seperti pada definisi di atas. |
| `402 plan_required` | Workspace tidak punya paket dan kiriman berasal dari situs live. Kiriman ditahan, tidak hilang. Pilih paket atau beli paket kiriman di **Settings › Plan and usage**, dan kiriman yang ditahan akan masuk. |

## Pertanyaan

### Bisakah formulir Nuxt berfungsi tanpa JavaScript?

Bisa. Beri formulir method dan action yang mengarah ke server route. readBody Nitro mem-parse kiriman urlencoded, dan route bisa menjawabnya dengan redirect, sementara kiriman $fetch dari halaman yang sudah di-hydrate mendapat JSON.

### Bisakah saya melakukan prerender halaman Nuxt yang punya formulir daftar tunggu?

Bisa. Prerender halaman dengan routeRules dan biarkan server route formulir tetap live, lalu build dengan nuxt build. Baca nilai query seperti ref setelah halaman di-mount.

### Di mana saya menyimpan secret key di Nuxt?

Di key runtimeConfig privat, yang diisi dari environment variable NUXT_ di host. Key privat hanya bisa dibaca di server, lewat useRuntimeConfig.

### 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

- [Daftar tunggu untuk SaaS baru](https://ikiform.com/id/guides/waitlist.md): Bangun daftar tunggu pra-peluncuran di landing page Anda sendiri: formulir, perlindungan spam, duplikat, email konfirmasi, dan ekspor rapi di hari peluncuran.
- [Daftar tunggu Next.js](https://ikiform.com/id/guides/waitlist/nextjs.md): Daftar tunggu Next.js App Router: server action yang meneruskan pendaftaran dengan secret key, useActionState, terima kasih optimistis, dan batas per pengunjung.
- [Daftar tunggu SvelteKit](https://ikiform.com/id/guides/waitlist/sveltekit.md): Daftar tunggu SvelteKit 3: hook menyimpan ?ref= pertama di cookie, form action bernama meneruskan pendaftaran, dan pengunjung yang kembali melihat bahwa mereka sudah terdaftar.
- [Pendaftaran beta dengan penyaringan](https://ikiform.com/id/guides/waitlist/beta-signup.md): Formulir lamaran beta dengan pertanyaan penyaringan yang ditegakkan server, dan skrip yang menilai pelamar serta memilih setiap kohort dari daftar.
- [Daftar peluncuran Product Hunt](https://ikiform.com/id/guides/waitlist/product-hunt.md): Kumpulkan daftar peluncuran berminggu-minggu sebelum Product Hunt, tandai setiap pendaftaran per channel, dan kirim email hari-H dalam dua gelombang mengikuti reset waktu Pasifik.
- [Daftar tunggu aplikasi mobile](https://ikiform.com/id/guides/waitlist/mobile-app.md): Daftar tunggu pra-peluncuran untuk aplikasi iOS atau Android yang menanyakan ponsel yang dipakai, mengirim penguji iPhone ke TestFlight, dan mengisi closed test Play.
