# Buat formulir feedback dalam aplikasi di Astro

> Formulir feedback dalam aplikasi Astro: action yang mengambil pengguna dari locals middleware, halaman dari request, dan layar dari pola route.

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

Tulis Astro action dengan `accept: "form"` yang memvalidasi rating dengan Zod, membaca pengguna yang login dari `context.locals`, dan meneruskan jawaban ke backend formulir Anda dengan secret dari `astro:env`. Render formulir dengan `action` diatur ke action tersebut, agar bisa mengirim tanpa JavaScript, dan tambahkan script pendek yang memanggil action yang sama di tempat saat JavaScript tersedia. Halaman yang dibahas jawaban berasal dari `Referer` request, dan `Astro.routePattern` menamai layarnya.

[Tab Astro di panduan utama](https://ikiform.com/id/guides/feedback-form.md#form) menanyakan kepada pengunjung mana pun seberapa berguna sebuah halaman. Halaman ini menaruh feedback di dalam aplikasi Astro dengan pengguna yang login, tempat server yang seharusnya menentukan siapa penulisnya dan formulir hanya meminta skor dan komentar. Untuk key dan penyiapan formulir, [panduan dokumentasi Astro](https://ikiform.com/id/docs/guides/astro.md) punya detailnya.

## Siapa mengisi apa

Astro action adalah endpoint publik, bisa dijangkau di URL yang dinamai sesuai action-nya. Siapa pun bisa memanggil `sendFeedback` dengan body rekayasa, jadi action tidak bisa memercayai ID pengguna yang diberikan kepadanya. Pisahkan nilai berdasarkan siapa yang bisa dipercaya untuk menyediakannya:

| Nilai | Diatur oleh | Cara |
| --- | --- | --- |
| `rating`, `comment` | Pengguna | Divalidasi oleh schema Zod milik action |
| `user_id`, `email` | Middleware | `context.locals.user`, dari cookie sesi di setiap request |
| `page` | Request | Header `Referer`, yaitu halaman tempat formulir berada |
| `screen` | Render halaman | `Astro.routePattern`, seperti `/app/projects/[id]/settings`, di input tersembunyi |

`routePattern` adalah alasan Astro terasa nyaman di sini. Ia menamai file yang merender halaman, sehingga layar pengaturan setiap proyek masuk ke satu label sementara `page` menyimpan alamat aslinya. Nilai ini melewati browser, jadi schema memeriksa bentuknya dan mengganti yang janggal dengan `unknown`.

Diagram: Dua cara satu formulir feedback Astro mencapai action yang sama

1. Tanpa JavaScript, browser mengirim formulir ke halaman saat ini, dengan nama action di URL.
2. Middleware berjalan lebih dulu di setiap request dan menaruh pengguna yang login di context.locals.
3. Action memvalidasi rating dan layar, menambahkan pengguna dan halaman, lalu mengirim ke Ikiform dengan secret.
4. Astro merender halaman lagi, dan Astro.getActionResult() menampilkan baris terima kasih atau error-nya.
5. Dengan JavaScript, script memanggil action yang sama lewat fetch. Middleware dan handler berjalan persis seperti sebelumnya.
6. Script mengganti formulir dengan baris terima kasih, atau menulis error di bawahnya, tanpa reload.

Satu handler, dua jalur. Kedua request berasal dari halaman, jadi keduanya membawa cookie sesi dan halaman di Referer.

## Setup proyek

`Struktur proyek`:

```text
src/
  actions/index.ts
  components/FeedbackForm.astro
  middleware.ts
  env.d.ts
  pages/app/projects/[id]/settings.astro
astro.config.mjs
.env
```

Deklarasikan secret di schema `astro:env`. Astro kemudian menolak berjalan tanpanya, memberi tipe pada import-nya, dan menjauhkannya dari bundle klien. Adapter server apa pun bisa dipakai; yang ditampilkan adalah Node.

`astro.config.mjs`:

```js
import { defineConfig, envField } from "astro/config";
import node from "@astrojs/node";

export default defineConfig({
  adapter: node({ mode: "standalone" }),
  env: {
    schema: {
      IKIFORM_SECRET_KEY: envField.string({ context: "server", access: "secret" }),
    },
  },
});
```

Taruh `IKIFORM_SECRET_KEY=ik_sk_...` di `.env` untuk pengembangan lokal. Buat key di **API keys** dengan scope `submissions:write`. Lalu tempel definisi ini di tab **Fields** formulir:

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)

Schema di action mencerminkan batas-batas ini, jadi sebagian besar input buruk ditolak sebelum menghabiskan satu request.

## Bangun

### Taruh pengguna di locals

Middleware berjalan sebelum setiap halaman dan action. Baca sesi Anda di sana sekali, dengan apa pun yang disediakan kode auth Anda, dan setiap handler mendapat hasilnya. `readSession` mewakili kode itu. Sesi bawaan Astro juga bisa dipakai: baca `context.session` di action sebagai gantinya.

`src/middleware.ts`:

```ts
import { defineMiddleware } from "astro:middleware";
import { readSession } from "./lib/auth";

export const onRequest = defineMiddleware(async (context, next) => {
  context.locals.user = await readSession(context.cookies.get("session")?.value);
  return next();
});
```

`src/env.d.ts`:

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

### Action-nya

`src/actions/index.ts`:

```ts
import { ActionError, defineAction } from "astro:actions";
import { IKIFORM_SECRET_KEY } from "astro:env/server";
import { z } from "astro/zod";

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

export const server = {
  sendFeedback: defineAction({
    accept: "form",
    input: z.object({
      rating: z.coerce.number().int().min(1, "Pick a score from 1 to 5.").max(5, "Pick a score from 1 to 5."),
      comment: z.string().max(4000).nullish(),
      screen: z.string().regex(/^\/[a-z0-9/[\]._-]{0,119}$/).catch("unknown"),
      _gotcha: z.string().nullish(),
    }),
    handler: async (input, context) => {
      const user = context.locals.user;
      if (!user) throw new ActionError({ code: "UNAUTHORIZED", message: "Your session ended. Sign in again, then send it." });

      const origin = context.request.headers.get("origin");
      const 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 ? { Origin: origin } : {}),
        },
        body: JSON.stringify({
          rating: input.rating,
          comment: input.comment ?? "",
          page: pageFrom(context.request.headers.get("referer")),
          screen: input.screen,
          user_id: user.id,
          email: user.email,
          _gotcha: input._gotcha ?? "",
        }),
        signal: AbortSignal.timeout(8000),
      }).catch(() => null);

      const result = await response?.json().catch(() => null);
      if (result?.ok) return { sent: true };
      throw new ActionError({
        code: "BAD_GATEWAY",
        message: result?.error?.message ?? "We couldn't send it. Try again in a moment.",
      });
    },
  }),
};
```

- Argumen kedua handler adalah konteks action. Isinya `locals`, `request`, dan `cookies`, tetapi tidak ada `redirect`, jadi sesi yang tidak ada menjadi error `UNAUTHORIZED` yang bisa ditampilkan formulir.
- `.catch("unknown")` pada `screen` mengubah label palsu menjadi `unknown` alih-alih menggagalkan seluruh jawaban.
- Input formulir yang kosong sampai ke action `form` sebagai `null`, itulah sebabnya `comment` dan honeypot bersifat `nullish`.
- Baik form post maupun panggilan dari script dikirim oleh halaman, jadi `Referer` berisi alamatnya di kedua kasus. `pageFrom` menyimpan origin dan path-nya.
- `Origin` diteruskan agar uji lokal dihitung sebagai `localhost`. Penjelasan lebih lanjut di bawah.

### Komponen formulir

`src/components/FeedbackForm.astro`:

```astro
---
import { actions, isInputError } from "astro:actions";

const result = Astro.getActionResult(actions.sendFeedback);
const scores = ["Broken", "Frustrating", "Fine", "Good", "Great"];
---

{result?.data?.sent ? (
  <p role="status">Thanks. We read every one.</p>
) : (
  <form method="POST" action={actions.sendFeedback} data-feedback>
    <fieldset>
      <legend>How is this screen working for you?</legend>
      {scores.map((label, index) => (
        <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"></textarea>
    <input type="hidden" name="screen" value={Astro.routePattern} />
    <input name="_gotcha" type="text" tabindex="-1" autocomplete="off" hidden />
    <p role="alert">
      {result?.error && (isInputError(result.error) ? result.error.fields.rating?.[0] : result.error.message)}
    </p>
    <button>Send feedback</button>
  </form>
)}

<script>
  import { actions } from "astro:actions";

  const form = document.querySelector<HTMLFormElement>("form[data-feedback]");
  form?.addEventListener("submit", async (event) => {
    event.preventDefault();
    const button = form.querySelector("button")!;
    const alert = form.querySelector("[role=alert]")!;
    button.disabled = true;
    button.textContent = "Sending";
    const { error } = await actions.sendFeedback(new FormData(form));
    if (!error) {
      form.replaceWith(Object.assign(document.createElement("p"), { role: "status", textContent: "Thanks. We read every one." }));
      return;
    }
    alert.textContent = error.message;
    button.disabled = false;
    button.textContent = "Send feedback";
  });
</script>
```

- `action={actions.sendFeedback}` membuat formulir bekerja tanpa JavaScript sama sekali. Astro menangani kiriman dan merender halaman dengan hasilnya.
- `isInputError` memisahkan kegagalan schema, yang punya `fields`, dari error yang dilempar handler, seperti pesan sesi.
- Script meng-import `actions` dari `astro:actions` dan memanggil handler yang sama dengan data formulir. Astro mem-bundle-nya, jadi tidak ada file tambahan.
- Submit tanpa JavaScript meninggalkan halaman sebagai hasil POST, dan refresh akan meminta pengiriman ulang. Jika itu mengganggu Anda, `getActionContext` di middleware memungkinkan Anda menyimpan hasilnya dan me-redirect.

### Halamannya

`src/pages/app/projects/[id]/settings.astro`:

```astro
---
import AppLayout from "../../../../layouts/AppLayout.astro";
import FeedbackForm from "../../../../components/FeedbackForm.astro";

export const prerender = false;

if (!Astro.locals.user) return Astro.redirect("/sign-in");
---

<AppLayout title="Project settings">
  <h1>Project settings</h1>
  <aside>
    <FeedbackForm />
  </aside>
</AppLayout>
```

## Mode rendering

Form action hanya bekerja di halaman yang dirender on demand. Halaman yang di-prerender dibangun sekali, jadi tidak ada request yang bisa dikirimi. Dengan output statis default, tandai setiap halaman aplikasi dengan `export const prerender = false`, atau atur `output: "server"` jika sebagian besar situs berada di balik login. Middleware tetap berjalan di halaman yang di-prerender saat build, ketika tidak ada cookie, jadi `locals.user` bernilai `null` di sana.

## Honeypot, pemeriksaan manusia, dan batas

Input tersembunyi `_gotcha` diteruskan, dan yang terisi disimpan sebagai spam. Pemeriksaan manusia tidak pernah berjalan untuk kiriman dengan secret key, dan kewajiban login menggantikan tugasnya. Secara default Astro memeriksa `Origin` dari form post ke halaman on demand, sehingga situs lain tidak bisa mengirim formulir di browser pengguna.

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.

## Deploy

| Host | Adapter | Secret-nya |
| --- | --- | --- |
| Vercel | `@astrojs/vercel` | Tambahkan `IKIFORM_SECRET_KEY` di environment variable proyek. Jika Anda memakai sesi Astro, konfigurasikan storage driver; Vercel tidak punya default. |
| Netlify | `@astrojs/netlify` | Tambahkan di environment variable situs. Sesi secara default memakai Netlify Blobs. |
| Cloudflare | `@astrojs/cloudflare` | Jalankan `npx wrangler secret put IKIFORM_SECRET_KEY` dan taruh di `.dev.vars` secara lokal. `astro:env/server` membacanya, begitu juga `env` dari `cloudflare:workers`. Sesi memakai binding KV bernama `SESSION`. |

Sejak Astro 6, `Astro.locals.runtime.env` sudah dihapus dari adapter Cloudflare. Kode yang membaca key dari sana perlu import `astro:env` di atas.

## Uji lokal gratis

Workspace tanpa paket mendapat 50 kiriman uji gratis per bulan dari `localhost`, dan Ikiform mengenalinya dari header `Origin`. Browser mengirim `http://localhost:4321` ke `astro dev`, tetapi `fetch` milik action ke Ikiform tidak mengirim origin. Baris `Origin` meneruskan origin browser, jadi uji lokal gratis, dan di produksi header-nya adalah domain Anda sendiri.

## Pastikan berfungsi

1. Jalankan `astro dev`, login, buka halaman pengaturan sebuah proyek, dan kirim nilai 2 dengan komentar.
2. Di **Submissions**, pastikan `screen` bernilai `/app/projects/[id]/settings`, `page` berisi ID asli, serta `user_id` dan `email` adalah milik Anda.
3. Matikan JavaScript dan kirim lagi. Halaman dimuat ulang dengan baris terima kasih.
4. Hapus cookie sesi lalu kirim. Formulir menampilkan pesan login.
5. Balas email notifikasi. Balasannya ditujukan ke pengguna yang login.

## Hal yang sering menjebak

| Masalah | Yang harus dilakukan |
| --- | --- |
| Formulir terkirim tetapi halaman tidak menampilkan hasil | Halaman di-prerender. Tambahkan `export const prerender = false` atau beralih ke `output: "server"`. |
| `IKIFORM_SECRET_KEY is missing` saat build dimulai | Secret divalidasi setiap kali `astro:env/server` di-import. Sediakan variabel itu saat build, atau placeholder di sana dan nilai aslinya saat runtime. |
| `402 plan_required` pada uji lokal | `Origin` yang diteruskan kosong atau bukan `localhost`. Buka aplikasi di `localhost:4321` dan pertahankan baris `Origin`. |
| `locals.user` selalu `null` di produksi | Nama atau domain cookie berbeda antar-environment, atau halaman masih di-prerender. Log `context.cookies.has("session")` di middleware. |
| `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 dalam Astro action?

Atur di context.locals di src/middleware.ts, lalu baca context.locals.user dari argumen kedua handler. Sesi Astro adalah jalur lainnya, lewat context.session.

### Apakah Astro action bekerja tanpa JavaScript?

Ya, di halaman yang dirender on demand. Atur action formulir ke action tersebut dan method-nya ke POST, lalu baca hasilnya dengan Astro.getActionResult() di halaman.

### Bagaimana saya tahu feedback berasal dari layar mana di Astro?

Render Astro.routePattern ke input tersembunyi. Nilainya adalah pola file route, seperti /app/projects/[id]/settings, sehingga jawaban dikelompokkan per layar, bukan per ID.

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