# Buat formulir feedback dalam aplikasi di SvelteKit

> Formulir feedback dalam aplikasi SvelteKit: formulir di layout mengirim ke satu form action, yang menambahkan pengguna dari locals dan halaman dari request di server.

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

Taruh formulir di layout yang membungkus layar untuk pengguna login dan kirim ke satu action bernama, `/feedback?/send`, dengan `use:enhance`. Hook `handle` menaruh pengguna di `event.locals`, sehingga action sendiri yang menambahkan ID dan email mereka serta membaca halaman dari request. Browser hanya mengirim skor, komentar, dan route id.

[Tab SvelteKit di panduan utama](https://ikiform.com/id/guides/feedback-form.md#form) mengirim JSON ke endpoint `+server.ts` dari halaman mana pun, login atau tidak. Halaman ini membangun versi untuk bagian dalam aplikasi: form action sungguhan yang bekerja sebelum JavaScript termuat, dengan identitas pengguna diambil dari sesi di server. Ditulis untuk SvelteKit 3 dan Svelte 5. Key dan allowed domains dibahas di [panduan dokumentasi SvelteKit](https://ikiform.com/id/docs/guides/sveltekit.md).

## Mengapa satu action untuk setiap layar

Form action milik sebuah halaman, tetapi formulir feedback ada di layout dan tampil di puluhan halaman. Menyalin action ke setiap `+page.server.ts` akan berantakan. SvelteKit memungkinkan formulir memanggil action route lain dengan query string, jadi setiap layar mengirim ke `/feedback?/send` dan satu file memegang logikanya. Setiap nilai kemudian berasal dari tempat yang tepat:

| Nilai | Sumber | Kepercayaan |
| --- | --- | --- |
| `rating`, `comment` | Formulirnya | Apa yang ingin disampaikan pengguna |
| `user_id`, `email` | `event.locals.user`, diatur oleh hook `handle` | Dibaca dari cookie sesi di server |
| `page` | `Referer` dari request action | Hanya same-origin; selebihnya dibuang |
| `screen` | `page.route.id` di input tersembunyi | Label pengelompokan. Diperiksa terhadap pola sebelum disimpan |

`page.route.id` adalah path file route, seperti `/(app)/projects/[id]/settings`. Semua halaman pengaturan proyek berbagi nilai ini, itulah yang Anda inginkan saat menghitung rata-rata skor per layar. Action menghapus segmen grup seperti `(app)`, karena itu hanya detail susunan folder Anda.

Diagram: Bagaimana kiriman feedback SvelteKit berjalan dari formulir layout melalui satu form action

1. Pengguna menilai layar di formulir layout. use:enhance mengirimnya ke /feedback?/send tanpa memuat halaman.
2. Hook handle berjalan lebih dulu, membaca cookie sesi, dan menaruh pengguna di event.locals.
3. Action send memeriksa bahwa ada pengguna dan skor bulat dari 1 sampai 5. Jika tidak, action mengembalikan fail dengan pesan.
4. Action menambahkan ID dan email pengguna, halaman dari Referer, dan route id yang sudah dibersihkan, lalu mengirim dengan secret key.
5. update dengan navigate diatur ke false menerapkan hasilnya ke layar saat ini, jadi baris terima kasih tampil di tempat pengguna berada.

Tanpa JavaScript, browser mendarat di /feedback, yang menampilkan hasil yang sama dan tautan kembali.

## Setup proyek

`Struktur proyek`:

```text
src/
  app.d.ts
  env.ts
  hooks.server.ts
  lib/
    FeedbackForm.svelte
    server/session.ts
  routes/
    (app)/+layout.svelte
    feedback/
      +page.server.ts
      +page.svelte
.env
```

Buat secret key dengan scope `submissions:write` di **API keys**. SvelteKit 3 mendeklarasikan environment variable di `src/env.ts`. Variabel bersifat privat dan dibaca saat runtime kecuali Anda menyatakan lain, jadi key tidak pernah sampai ke bundle browser dan bisa diubah di host tanpa rebuild. `.env` mengisinya selama pengembangan.

`.env`:

```bash
IKIFORM_SECRET_KEY=ik_sk_your_secret_key
```

`src/env.ts`:

```ts
import { defineEnvVars } from "@sveltejs/kit/env";

export const variables = defineEnvVars({
  IKIFORM_SECRET_KEY: {},
});
```

Tempel definisi ini di tab **Fields** formulir. Formulir menampilkan skor dan komentar; action menyediakan 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)

Kirim skor `6` atau field yang tidak ada di definisi, dan preview menampilkan `422` yang diubah action menjadi pesan.

## Bangun

### Taruh pengguna di locals

`handle` berjalan untuk setiap request yang diterima server, termasuk kiriman form action. Beri tipe pengguna sekali di `app.d.ts`, lalu baca dari sesi Anda di hook. `readSession` mewakili apa pun yang sudah dilakukan kode auth Anda dengan cookie.

`src/app.d.ts`:

```ts
declare global {
  namespace App {
    interface Locals {
      user: { id: string; email: string } | null;
    }
  }
}

export {};
```

`src/hooks.server.ts`:

```ts
import type { Handle } from "@sveltejs/kit/hooks";
import { readSession } from "#lib/server/session.js";

export const handle: Handle = async ({ event, resolve }) => {
  event.locals.user = await readSession(event.cookies.get("session"));
  return resolve(event);
};
```

### Form action

`src/routes/feedback/+page.server.ts`:

```ts
import { fail } from "@sveltejs/kit";
import { IKIFORM_SECRET_KEY } from "$app/env/private";
import type { Actions } from "./$types";

export const prerender = false;

const SCREEN = /^[a-z0-9/[\]._-]{1,120}$/;

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

export const actions = {
  send: async ({ request, locals, url }) => {
    const data = await request.formData();
    if (!locals.user) return fail(401, { message: "Your session ended. Sign in again, then send it." });

    const rating = Number(data.get("rating"));
    if (!Number.isInteger(rating) || rating < 1 || rating > 5) {
      return fail(400, { message: "Pick a score from 1 to 5." });
    }

    const screen = String(data.get("screen") ?? "").replace(/\/\([^)]+\)/g, "") || "/";
    const back = pageFrom(request.headers.get("referer"), url.origin);

    let response: Response;
    try {
      response = await fetch("https://ikiform.com/f/frm_xxxxxxxxxxxxxxxx", {
        method: "POST",
        headers: {
          Accept: "application/json",
          "Content-Type": "application/json",
          Authorization: `Bearer ${IKIFORM_SECRET_KEY}`,
          Origin: request.headers.get("origin") ?? url.origin,
        },
        body: JSON.stringify({
          rating,
          comment: String(data.get("comment") ?? "").trim().slice(0, 4000),
          page: back,
          screen: SCREEN.test(screen) ? screen : "unknown",
          user_id: locals.user.id,
          email: locals.user.email,
          _gotcha: String(data.get("_gotcha") ?? ""),
        }),
        signal: AbortSignal.timeout(8000),
      });
    } catch {
      return fail(502, { message: "We couldn't send it. Try again in a moment." });
    }

    if (response.status === 429) return fail(429, { message: "Too many answers at once. Try again in a minute." });
    const result = await response.json().catch(() => null);
    if (!result?.ok) {
      return fail(400, { message: result?.error?.fields?.comment?.[0] ?? result?.error?.message ?? "We couldn't send it." });
    }
    return { sent: true, back };
  },
} satisfies Actions;
```

- `fail` mengembalikan status dan data tanpa melempar exception. Di SvelteKit 3, formulir yang di-enhance juga menerima status itu, jadi `401` dan `400` muncul di tab network sebagaimana adanya.
- `pageFrom` hanya menyimpan `Referer` yang berasal dari origin Anda sendiri, dan membuang query string-nya.
- `export const prerender = false` sengaja ada di sana. Halaman dengan action tidak bisa di-prerender, jadi jika root layout Anda melakukan prerender secara default, build gagal tanpanya.
- Baris `Origin` meneruskan origin browser, dengan fallback ke origin aplikasi sendiri, dan memberi tahu Ikiform dari mana request berasal. Itulah yang membuat uji lokal dihitung sebagai `localhost`, di bawah.
- `429` dari Ikiform punya pesan sendiri, karena di balik server artinya formulir secara keseluruhan sedang sibuk, bukan pengguna ini.

### Formulir layout

`src/lib/FeedbackForm.svelte`:

```svelte
<script lang="ts">
  import { enhance } from "$app/forms";
  import { page } from "$app/state";

  const scores = ["Broken", "Frustrating", "Fine", "Good", "Great"];
  let sending = $state(false);
</script>

{#if page.form?.sent}
  <p role="status">Thanks. We read every one.</p>
{:else}
  <form
    method="POST"
    action="/feedback?/send"
    aria-busy={sending}
    use:enhance={() => {
      sending = true;
      return async ({ update }) => {
        await update({ navigate: false, refreshAll: false });
        sending = false;
      };
    }}
  >
    <fieldset>
      <legend>How is this screen working for you?</legend>
      {#each scores as label, index}
        <label><input type="radio" name="rating" value={index + 1} required /> {label}</label>
      {/each}
    </fieldset>
    <label for="feedback-comment">What would make it better?</label>
    <textarea id="feedback-comment" name="comment" rows="4" maxlength="4000"></textarea>
    <input type="hidden" name="screen" value={page.route.id ?? ""} />
    <input name="_gotcha" type="text" tabindex="-1" autocomplete="off" hidden />
    {#if page.form?.message}
      <p role="alert">{page.form.message}</p>
    {/if}
    <button disabled={sending}>{sending ? "Sending" : "Send feedback"}</button>
  </form>
{/if}
```

- Secara default, formulir yang di-enhance dengan action di halaman lain akan bernavigasi ke sana setelah kiriman. `navigate: false` menjaga pengguna tetap di layarnya dan menaruh hasilnya di `page.form` di sini.
- `refreshAll: false` melewati menjalankan ulang setiap fungsi `load` setelah berhasil. Feedback tidak mengubah apa pun yang ditampilkan halaman.
- Saat gagal, formulir tidak direset, jadi komentar tetap ada di kotak di samping pesannya.
- `page.form` milik halaman saat ini. Saat pengguna bernavigasi, nilainya dikosongkan, dan layar berikutnya menampilkan formulir baru.

`src/routes/(app)/+layout.svelte`:

```svelte
<script lang="ts">
  import FeedbackForm from "#lib/FeedbackForm.svelte";

  let { children } = $props();
</script>

<main>{@render children()}</main>
<aside>
  <FeedbackForm />
</aside>
```

Tanpa JavaScript, formulir yang sama tetap bekerja. Browser mengirim ke `/feedback?/send` dan mendarat di `/feedback`, yang merender hasil dan tautan kembali ke halaman asalnya:

`src/routes/feedback/+page.svelte`:

```svelte
<script lang="ts">
  import type { PageProps } from "./$types";

  let { form }: PageProps = $props();
</script>

{#if form?.sent}
  <p role="status">Thanks. We read every one.</p>
  <a href={form.back || "/"}>Back to where you were</a>
{:else if form?.message}
  <p role="alert">{form.message}</p>
  <a href="/">Back to the app</a>
{:else}
  <p>Send feedback from the panel on any screen of the app.</p>
{/if}
```

## Honeypot, pemeriksaan manusia, dan batas

Input tersembunyi `_gotcha` diteruskan tanpa diubah, dan Ikiform menyimpan yang terisi sebagai spam. Pemeriksaan manusia Ikiform dilewati untuk kiriman dengan secret key. Di sini action sudah menolak siapa pun tanpa sesi, yang menahan bot yang akan dihentikan pemeriksaan manusia. Yang tersisa adalah akun yang login mengirim terus-menerus, jadi atur preset rate limit untuk formulir yang mengirim dari satu server.

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.

## Rendering dan deploy

Layar di sekitar formulir bisa dirender di server atau di-prerender sesuka Anda; formulir sendiri tidak butuh apa pun saat render. Hanya `/feedback` yang harus berjalan di server. Adapter diatur di `vite.config.ts`, di dalam plugin `sveltekit()`.

| Host | Adapter dan key |
| --- | --- |
| Vercel | `@sveltejs/adapter-vercel`. Tambahkan `IKIFORM_SECRET_KEY` di **Environment Variables**. Edge runtime sudah tidak ada di SvelteKit 3, jadi action berjalan di Node function. |
| Netlify | `@sveltejs/adapter-netlify`. Atur key di environment variable situs. |
| Cloudflare | `@sveltejs/adapter-cloudflare` dengan Wrangler 4.118 atau lebih baru, peer dependency-nya. Simpan key sebagai secret Worker, misalnya dengan `wrangler secret put IKIFORM_SECRET_KEY`, dan baca lewat `$app/env/private` seperti di atas. |

## Uji gratis di localhost

Tanpa paket, workspace menerima 50 kiriman uji per bulan dari `localhost`. Ikiform menentukannya dari header `Origin`, dan `fetch` dari server Anda tidak mengirimnya kecuali Anda mengaturnya. Itulah sebabnya action meneruskan `Origin` dari request: di `vite dev` nilainya `http://localhost:5173`, dan kiriman dihitung sebagai uji gratis. Hilangkan baris itu dan uji lokal kembali dengan `402 plan_required`, ditahan sampai Anda memilih paket.

## Pastikan berfungsi

1. Jalankan `vite dev`, login, buka layar pengaturan sebuah proyek, dan kirim skor 2 dengan komentar.
2. Buka **Submissions**. `screen` berisi `/projects/[id]/settings`, `page` berisi ID proyek asli, serta `user_id` dan `email` cocok dengan akun Anda.
3. Hapus cookie sesi dan kirim lagi. Formulir menampilkan pesan login dan tetap menyimpan komentar Anda.
4. Matikan JavaScript lalu kirim. Anda mendarat di `/feedback` dengan baris terima kasih dan tautan kembali ke layar.
5. Balas email notifikasi. Balasannya menuju pengguna yang login.

## Hal yang sering menjebak

| Masalah | Yang harus dilakukan |
| --- | --- |
| Ucapan terima kasih tampil di `/feedback` alih-alih di layar saat ini | Callback enhance memanggil `update()` tanpa `navigate: false`, atau formulirnya tidak di-enhance. Periksa blok `use:enhance`. |
| `locals.user` selalu `null` di action | Hook membaca nama cookie yang berbeda, atau `path` cookie tidak mencakup `/feedback`. SvelteKit 3 mengatur cookie baru ke `/` secara default, tetapi cookie yang diatur kode lama mungkin dibatasi ke subpath. |
| Kiriman dari domain lain milik Anda ditolak | SvelteKit selalu memeriksa origin form post. Tambahkan origin itu ke `csrf.trustedOrigins` alih-alih membukanya untuk semua orang. |
| Build gagal dengan error tentang prerendering `/feedback` | Halaman dengan action tidak bisa di-prerender. Pertahankan `export const prerender = false` di `+page.server.ts`-nya. |
| `422 validation_failed` | Kiriman tidak cocok dengan definisi field formulir. `error.fields` menyebutkan setiap field dan alasannya. Samakan `name` input dengan ID field. |

## Pertanyaan

### Bisakah formulir SvelteKit memanggil action di route lain?

Bisa. Atur action formulir ke route lain ditambah nama action-nya, seperti /feedback?/send. Dengan use:enhance, teruskan navigate: false ke update agar hasilnya tampil di halaman saat ini alih-alih bernavigasi ke sana.

### Bagaimana cara mendapatkan pengguna yang login di dalam form action SvelteKit?

Baca sesi di hook handle dan atur event.locals.user. Setiap action menerima locals, jadi action bisa melampirkan ID dan email pengguna tanpa memercayai apa pun yang dikirim browser.

### Mengapa route feedback saya merusak build prerender?

Halaman dengan form action tidak bisa di-prerender, karena server harus menjawab POST-nya. Atur prerender ke false di route yang memegang action.

### 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 Nuxt](https://ikiform.com/id/guides/feedback-form/nuxt.md): Formulir feedback dalam aplikasi Nuxt: server route yang membaca sesi pengguna, mengambil halaman dari Referer, dan tetap bekerja saat JavaScript belum termuat.
- [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.
