# Buat formulir feedback dalam aplikasi di Nuxt

> Formulir feedback dalam aplikasi Nuxt: server route yang membaca sesi pengguna, mengambil halaman dari Referer, dan tetap bekerja saat JavaScript belum termuat.

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

Kirim formulir dua pertanyaan ke server route Nuxt di `server/api/feedback.post.ts`. Route membaca pengguna yang login dengan `getUserSession` dari nuxt-auth-utils, mengambil halaman dari `Referer` request, memvalidasi skor dengan Zod, dan meneruskan semuanya ke backend formulir Anda dengan secret key yang disimpan di `runtimeConfig`. Dengan JavaScript, formulir mengirim lewat `$fetch` dan tetap di tempat; tanpa JavaScript, route yang sama me-redirect kembali dengan hasil di URL.

[Tab Nuxt di panduan utama](https://ikiform.com/id/guides/feedback-form.md#form) membuat feedback halaman untuk siapa pun yang berkunjung. Di dalam aplikasi Nuxt dengan akun, tugasnya lebih sempit dan datanya lebih kaya: pengguna sudah login, server tahu siapa mereka, dan router tahu layar mana yang mereka buka. Halaman ini membangun versi itu. Key, allowed domains, dan penyiapan produk lainnya ada di [panduan dokumentasi Nuxt](https://ikiform.com/id/docs/guides/nuxt.md).

## Apa yang diubah aplikasi Nuxt dengan login

Di formulir publik, browser yang menentukan apa yang disimpan. Di sini, browser hanya menentukan skor dan komentar. Semua yang menyatakan siapa dan di mana ditambahkan oleh server route, tempat tidak ada yang bisa mengubahnya:

| Field | Diatur oleh | Cara |
| --- | --- | --- |
| `rating`, `comment` | Pengguna | Tombol radio dan kotak teks |
| `user_id`, `email` | Server route | `getUserSession(event)` membaca cookie sesi yang tersegel |
| `page` | Server route | Header `Referer`, yang dikirim browser pada kiriman same-origin dengan path lengkap |
| `screen` | Komponen, diperiksa oleh route | `useRoute().name`, seperti `projects-id-settings`, dibuat Nuxt dari path file halaman |

Nama route adalah kunci pengelompokan gratis di Nuxt. Halaman pengaturan setiap proyek berbagi `projects-id-settings`, jadi Anda bisa menghitung rata-rata skor per layar sementara `page` menyimpan alamat persisnya.

Diagram: Bagaimana satu server route Nuxt menangani feedback dengan dan tanpa JavaScript

1. Setelah hydration, komponen menghentikan submit native dan mengirim field sebagai JSON dengan $fetch.
2. Server route memeriksa Origin, membaca pengguna dari sesi, memvalidasi skor, dan menambahkan halaman dari Referer.
3. Route meneruskan jawaban ke Ikiform dengan secret key dari runtimeConfig.
4. Request JSON mendapat { sent: true } atau error dengan pesan, dan formulir diperbarui di tempat.
5. Sebelum hydration, browser sendiri yang mengirim formulir. Route menjalankan pemeriksaan dan kiriman yang sama.
6. Lalu route menjawab dengan 303 kembali ke halaman, dengan hasilnya di query string untuk ditampilkan komponen.

Route membedakan keduanya dari content type request. Kedua jalur menyimpan jawaban yang persis sama.

## Setup proyek

Ini memakai struktur Nuxt 4, dengan kode aplikasi di `app/` dan kode server di root. Instal `nuxt-auth-utils` dan `zod`. Jika aplikasi Anda sudah punya cara login lain, pertahankan dan baca pengguna dari sesi Anda sendiri di route.

`Struktur proyek`:

```text
app/
  components/FeedbackForm.vue
  pages/projects/[id]/settings.vue
server/
  api/feedback.post.ts
shared/
  types/auth.d.ts
nuxt.config.ts
.env
```

Deklarasikan secret key di `runtimeConfig` dengan default kosong. Nuxt mengisinya saat runtime dari `NUXT_IKIFORM_SECRET_KEY`, dan karena key tidak berada di bawah `public`, key hanya ada di server.

`nuxt.config.ts`:

```ts
export default defineNuxtConfig({
  modules: ["nuxt-auth-utils"],
  runtimeConfig: {
    ikiformSecretKey: "",
  },
});
```

`.env`:

```bash
NUXT_IKIFORM_SECRET_KEY=ik_sk_your_secret_key
NUXT_SESSION_PASSWORD=at-least-32-characters-long-random-string
```

Buat key di **API keys** dengan scope `submissions:write`. nuxt-auth-utils menyegel cookie sesinya dengan `NUXT_SESSION_PASSWORD`, yang minimal 32 karakter; di pengembangan, ia menuliskannya untuk Anda saat pertama kali dijalankan. Beri tahu bentuk pengguna Anda, agar `session.user.id` punya tipe:

`shared/types/auth.d.ts`:

```ts
declare module "#auth-utils" {
  interface User {
    id: string;
    email: string;
  }
}

export {};
```

Lalu tempel definisi di tab **Fields** formulir. Dua field pertama adalah formulirnya; server route menambahkan sisanya, dan field `email` membuat **Reply** di setiap notifikasi menuju ke pengguna.

Formulir: In-app feedback

- **Bagaimana layar ini bekerja untuk Anda?** (`rating`, rating, wajib)
- **Apa yang bisa membuatnya lebih baik?** (`comment`, textarea)
- **Halaman** (`page`, url)
- **Layar** (`screen`, text)
- **Pengguna** (`user_id`, text)
- **Email** (`email`, email)

Preview menjalankan validator Ikiform, jadi skor `6` menampilkan `422` yang akan diterima route.

## Bangun

### Server route

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

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

const Feedback = z.object({
  rating: z.coerce.number().int().min(1).max(5),
  comment: z.string().trim().max(4000).default(""),
  screen: z.string().regex(/^[a-z0-9_-]{1,120}$/).catch("unknown"),
  _gotcha: z.string().default(""),
});

function pageFrom(referer: string | undefined) {
  try {
    const url = new URL(referer ?? "");
    return url.origin + url.pathname;
  } catch {
    return "";
  }
}

export default defineEventHandler(async (event) => {
  const own = getRequestURL(event, { xForwardedHost: true });
  if (getRequestHeader(event, "origin") !== own.origin) {
    throw createError({ status: 403, message: "Posts from other sites are refused." });
  }

  const wantsJson = getRequestHeader(event, "content-type")?.includes("application/json") ?? false;
  const referer = getRequestHeader(event, "referer");
  const back = (outcome: string) => {
    const target = new URL(referer ?? "/", own.origin);
    return sendRedirect(event, `${target.pathname}?feedback=${outcome}`, 303);
  };

  const session = await getUserSession(event);
  if (!session.user) {
    if (wantsJson) throw createError({ status: 401, message: "Your session ended. Sign in again, then send it." });
    return back("signed-out");
  }

  const parsed = await readValidatedBody(event, (body) => Feedback.safeParse(body));
  if (!parsed.success) {
    if (wantsJson) throw createError({ status: 422, message: "Pick a score from 1 to 5." });
    return back("invalid");
  }

  const { ikiformSecretKey } = useRuntimeConfig(event);
  const response = await fetch("https://ikiform.com/f/frm_xxxxxxxxxxxxxxxx", {
    method: "POST",
    headers: {
      Accept: "application/json",
      "Content-Type": "application/json",
      Authorization: `Bearer ${ikiformSecretKey}`,
      Origin: own.origin,
    },
    body: JSON.stringify({
      ...parsed.data,
      page: pageFrom(referer),
      user_id: session.user.id,
      email: session.user.email,
    }),
    signal: AbortSignal.timeout(8000),
  }).catch(() => null);

  const result = await response?.json().catch(() => null);
  if (result?.ok) return wantsJson ? { sent: true } : back("sent");
  if (wantsJson) {
    throw createError({ status: 502, message: result?.error?.message ?? "We couldn't send it. Try again." });
  }
  return back("failed");
});
```

- Akhiran `.post.ts` membuat file hanya menjawab `POST`.
- Pemeriksaan Origin berjalan sebelum hal lain. `readBody`, yang dipakai `readValidatedBody`, juga mem-parsing body urlencoded, jadi tanpa pemeriksaan itu situs lain bisa mengirim formulir biasa ke route ini dengan cookie pengguna Anda terlampir.
- `getUserSession` tidak melempar exception. Route sendiri yang memilih jawabannya: `401` untuk `$fetch`, redirect untuk kiriman biasa. `requireUserSession` akan mengirim halaman `401` ke pengguna tanpa JavaScript.
- `z.coerce.number()` mengubah `"4"` dari urlencoded menjadi `4`. `.catch("unknown")` menyimpan `screen` yang salah bentuk sebagai `unknown` alih-alih menolak seluruh jawaban.
- `back` hanya menyimpan path dari `Referer`, jadi redirect tidak pernah bisa keluar dari situs Anda.

### Komponennya

`app/components/FeedbackForm.vue`:

```vue
<script setup lang="ts">
const MESSAGES: Record<string, string> = {
  "signed-out": "Your session ended. Sign in again, then send it.",
  invalid: "Pick a score from 1 to 5.",
  failed: "We couldn't send it. Try again in a moment.",
};
const SCORES = ["Broken", "Frustrating", "Fine", "Good", "Great"];

const route = useRoute();
const { loggedIn } = useUserSession();
const screen = computed(() => String(route.name ?? "unknown"));
const outcome = String(route.query.feedback ?? "");
const status = ref<"idle" | "sending" | "sent">(outcome === "sent" ? "sent" : "idle");
const error = ref(MESSAGES[outcome] ?? "");

async function send(event: SubmitEvent) {
  const body = Object.fromEntries(new FormData(event.currentTarget as HTMLFormElement));
  status.value = "sending";
  error.value = "";
  try {
    await $fetch("/api/feedback", { method: "POST", body });
    status.value = "sent";
  } catch (failure) {
    error.value = (failure as { data?: { message?: string } }).data?.message ?? "We couldn't send it. Try again in a moment.";
    status.value = "idle";
  }
}
</script>

<template>
  <template v-if="loggedIn">
    <p v-if="status === 'sent'" role="status">Thanks. We read every one.</p>
    <form v-else method="post" action="/api/feedback" :aria-busy="status === 'sending'" @submit.prevent="send">
      <fieldset>
        <legend>How is this screen working for you?</legend>
        <label v-for="(label, index) in SCORES" :key="label">
          <input type="radio" name="rating" :value="index + 1" required /> {{ label }}
        </label>
      </fieldset>
      <label for="feedback-comment">What would make it better?</label>
      <textarea id="feedback-comment" name="comment" rows="4" maxlength="4000" />
      <input type="hidden" name="screen" :value="screen" />
      <input name="_gotcha" type="text" tabindex="-1" autocomplete="off" hidden />
      <p v-if="error" role="alert">{{ error }}</p>
      <button :disabled="status === 'sending'">{{ status === "sending" ? "Sending" : "Send feedback" }}</button>
    </form>
  </template>
</template>
```

- `method` dan `action` adalah atribut sungguhan, jadi sebelum hydration browser mengirim formulir ke route. Begitu Vue berjalan, `@submit.prevent` mengambil alih.
- Body JSON memberi tahu route bahwa ia bisa menjawab dengan JSON. Body berasal dari `FormData`, jadi honeypot dan `screen` ikut terkirim tanpa kode tambahan.
- Pada `$fetch` yang gagal, `data.message` dari error adalah pesan dari `createError` di route.
- Setelah kiriman biasa, `?feedback=` di URL mengatur state awal, jadi ucapan terima kasih atau error tampil di halaman yang dimuat ulang.
- `useUserSession` menyembunyikan formulir dari pengunjung yang belum login. Ini hanya kemudahan, bukan pemeriksaan; route yang melakukan pemeriksaan.

Taruh `<FeedbackForm />` di halaman mana pun di bagian aplikasi untuk pengguna login, seperti `app/pages/projects/[id]/settings.vue`. Nuxt meng-import-nya otomatis.

## Honeypot, pemeriksaan manusia, dan batas

Input `_gotcha` sampai ke Ikiform bersama field lainnya, dan yang terisi disimpan sebagai spam. Pemeriksaan manusia Ikiform dilewati untuk kiriman dengan secret key, dan di sini sesi yang menjalankan tugas itu: route menolak siapa pun yang belum login. Yang tersisa adalah akun yang login mengirim terus-menerus, yang hanya bisa dihitung Ikiform berdasarkan alamat server Anda.

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.

## Pilihan rendering

Formulir butuh server rendering untuk hasil tanpa JavaScript. Pada route dengan `ssr: false` di `routeRules`, halaman kosong sampai JavaScript berjalan, jadi kiriman biasa tidak pernah terjadi dan `?feedback=` hanya dibaca di browser. Itu tidak masalah, hanya menghilangkan fallback-nya. Jangan taruh formulir di route `prerender: true`: HTML dibangun sekali, jadi `useUserSession` tidak melihat siapa pun dan formulir tidak muncul sampai hydration.

## Deploy

| Host | Yang harus dilakukan |
| --- | --- |
| Vercel | Nitro mendeteksi Vercel, jadi tidak perlu preset. Tambahkan `NUXT_IKIFORM_SECRET_KEY` dan `NUXT_SESSION_PASSWORD` di **Environment Variables** lalu deploy ulang. |
| Netlify | Juga terdeteksi. Tambahkan kedua variabel di pengaturan environment situs dengan scope Functions; server route berjalan sebagai function. |
| Cloudflare | Build dengan preset `cloudflare_module` untuk Workers, atau hubungkan repo ke Pages, yang terdeteksi otomatis. Simpan kedua nilai sebagai secret, dan taruh di `.dev.vars` untuk `wrangler dev`. |

Di Cloudflare, nilai environment hanya ada selama request berjalan. Route membaca `useRuntimeConfig(event)` di dalam handler, memang di sanalah tempatnya.

## Uji gratis di localhost

Tanpa paket, workspace menerima 50 kiriman uji per bulan dari `localhost`, dinilai dari header `Origin`. `fetch` dari route Nitro tidak punya browser di belakangnya dan tidak mengirim `Origin` kecuali Anda mengaturnya. Itulah sebabnya route meneruskan `own.origin`: di bawah `nuxt dev` nilainya `http://localhost:3000` dan dihitung sebagai uji, dan di produksi nilainya domain Anda. Hilangkan itu dan setiap uji lokal kembali dengan `402 plan_required`.

## Pastikan berfungsi

1. Jalankan `nuxt dev`, login, buka halaman pengaturan sebuah proyek, dan kirim skor 2 dengan komentar.
2. Di **Submissions**, pastikan `user_id` dan `email` cocok dengan akun Anda, `screen` bernilai `projects-id-settings`, dan `page` berisi path asli.
3. Matikan JavaScript dan kirim lagi. Halaman dimuat ulang dengan baris terima kasih dan `?feedback=sent` di alamat.
4. Kirim ke `/api/feedback` dengan `curl` tanpa `Origin`. Route menjawab `403` dan tidak ada yang sampai ke Ikiform.
5. Hapus cookie sesi dan kirim dari halaman yang terbuka. Formulir menampilkan pesan login.

## Hal yang sering menjebak

| Masalah | Yang harus dilakukan |
| --- | --- |
| `403` dari route Anda sendiri di produksi | Proxy mengubah host, sehingga `getRequestURL` tidak sesuai dengan `Origin` browser. Buat proxy mengirim `X-Forwarded-Host`, yang dibaca oleh `xForwardedHost: true`. |
| `screen` bernilai `unknown` di setiap jawaban | Nama route dengan akhiran locale, seperti dari `@nuxtjs/i18n`, mengandung karakter yang ditolak pola. Perluas regex-nya, atau hapus akhiran itu di komponen. |
| Secret key kosong di produksi | Nama variabel harus `NUXT_` ditambah key dalam upper snake case, `NUXT_IKIFORM_SECRET_KEY`, dan key itu harus ada di `runtimeConfig`. |
| `402 plan_required` pada uji lokal | Kiriman keluar tanpa `Origin`. Pertahankan header `Origin: own.origin` dan buka aplikasi di `localhost`. |
| `422 validation_failed` | Kiriman tidak cocok dengan definisi field formulir. `error.fields` menyebutkan setiap field dan alasannya. Samakan `name` input dengan ID field. |

## Pertanyaan

### Bagaimana cara mendapatkan pengguna yang login di server route Nuxt?

Dengan nuxt-auth-utils, panggil getUserSession(event) atau requireUserSession(event) di dalam handler dan baca session.user. Tanpanya, atur event.context.user di server middleware dan baca nilai itu. Jangan pernah mengambil pengguna dari body request.

### Bisakah formulir Nuxt berfungsi tanpa JavaScript?

Bisa. Beri formulir method dan action sungguhan yang mengarah ke server route, dan buat route me-redirect kembali dengan 303 saat request-nya bukan JSON. Setelah hydration, cegat submit dan gunakan $fetch.

### Bagaimana Nuxt membaca environment variable NUXT_?

Deklarasikan key di runtimeConfig di nuxt.config, dan Nuxt menimpanya saat runtime dari environment variable bernama NUXT_ ditambah key dalam upper snake case. Key di luar public tetap di server.

### 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 feedback untuk website atau aplikasi](https://ikiform.com/id/guides/feedback-form.md): Pasang formulir feedback di halaman mana pun atau di dalam aplikasi Anda: rating, komentar, halaman asalnya, filter spam, dan peringatan saat skornya rendah.
- [Formulir feedback SvelteKit](https://ikiform.com/id/guides/feedback-form/sveltekit.md): Formulir feedback dalam aplikasi SvelteKit: formulir di layout mengirim ke satu form action, yang menambahkan pengguna dari locals dan halaman dari request di server.
- [Formulir feedback Astro](https://ikiform.com/id/guides/feedback-form/astro.md): Formulir feedback dalam aplikasi Astro: action yang mengambil pengguna dari locals middleware, halaman dari request, dan layar dari pola route.
- [Formulir feedback Next.js](https://ikiform.com/id/guides/feedback-form/nextjs.md): Formulir feedback dalam aplikasi Next.js: server action yang membaca sesi, mengambil halaman dari request, dan meneruskan rating beserta penggunanya.
- [Formulir laporan bug](https://ikiform.com/id/guides/feedback-form/bug-report.md): Formulir laporan bug yang menanyakan langkah dan tingkat keparahan, dan membiarkan halaman menambahkan URL, browser, viewport, dan error terbaru dengan sendirinya.
- [Formulir permintaan fitur](https://ikiform.com/id/guides/feedback-form/feature-request.md): Formulir permintaan fitur yang menanyakan masalahnya alih-alih solusinya, menyimpan permintaan per tema, menghitung orang bukan baris, dan mengirim email saat fiturnya rilis.
