# Buat formulir feedback dalam aplikasi di Next.js

> Formulir feedback dalam aplikasi Next.js: server action yang membaca sesi, mengambil halaman dari request, dan meneruskan rating beserta penggunanya.

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

Taruh formulir klien kecil dengan rating 1 sampai 5 di layar yang ingin Anda mintai feedback, dan arahkan ke server action. Action membaca pengguna yang login dari cookie sesi, mengambil halaman dari `Referer` request, dan meneruskan semuanya ke backend formulir Anda dengan secret key. Browser tidak pernah mengirim siapa penggunanya, sehingga tidak ada yang bisa mengirim feedback atas nama orang lain.

[Tab Next.js di panduan utama](https://ikiform.com/id/guides/feedback-form.md#form) adalah formulir feedback halaman untuk pengunjung mana pun. Halaman ini untuk bagian dalam aplikasi Anda, tempat orang sudah login. Di sana Anda sudah tahu siapa yang menulis dan di layar mana mereka berada, jadi formulir mengajukan dua pertanyaan dan server mengisi sisanya. Penyiapan produk, key, dan allowed domains ada di [panduan dokumentasi Next.js](https://ikiform.com/id/docs/guides/nextjs.md).

## Apa yang berubah saat pengguna sudah login

Formulir feedback publik menerima apa yang diberikan browser. Formulir di dalam aplikasi seharusnya tidak. Jika browser mengirim `user_id`, siapa pun bisa mengubahnya di dev tools dan mengirim feedback sebagai rekan kerja atau pelanggan. Setiap nilai sebaiknya berasal dari tempat yang bisa dipercaya untuknya:

| Nilai | Berasal dari | Mengapa di sana |
| --- | --- | --- |
| `rating`, `comment` | Formulirnya | Hanya pengguna yang mengetahuinya |
| `user_id`, `email` | Cookie sesi, dibaca di action | Browser tidak bisa memalsukan sesi yang ditandatangani |
| `page` | Header `Referer` dari request action | Server action mengirim ke halaman yang merendernya, jadi header berisi URL lengkap |
| `screen` | Prop yang diteruskan halaman, seperti `projects/[id]/settings` | Mengelompokkan halaman pengaturan setiap proyek di bawah satu nama, sehingga rata-ratanya bermakna |

`screen` adalah satu-satunya nilai yang melewati browser. Nilai ini adalah label untuk pengelompokan, bukan izin, dan action memeriksa bentuknya sebelum disimpan.

Diagram: Bagaimana jawaban feedback Next.js mendapatkan pengguna dan halaman di server

1. Pengguna memilih skor dan mungkin menulis komentar. Formulir mengirimnya bersama nama layar.
2. Server action berjalan sebagai POST ke halaman tempat formulir berada, dengan cookie sesi dan halaman di Referer.
3. getUser() membaca dan memeriksa cookie sesi. Tanpa sesi yang valid, tidak ada yang dikirim.
4. Action menambahkan ID pengguna, email, dan alamat halaman, lalu mengirim ke Ikiform dengan secret key.
5. Jawaban Ikiform menjadi state action: baris terima kasih, atau error dengan komentar yang masih tertulis.

Sesi dibaca saat formulir dikirim, bukan saat halaman dirender. Halaman di sekitar formulir bisa tetap statis.

## Setup proyek

Empat file, salah satunya mungkin sudah Anda punya. Layout App Router-nya yang biasa:

`Struktur proyek`:

```text
app/
  (app)/
    projects/[id]/settings/page.tsx
  feedback/
    actions.ts
    feedback-form.tsx
lib/
  user.ts
.env.local
```

Buat secret key di **API keys** dengan scope `submissions:write` dan taruh di `.env.local`. Jangan pernah memberinya awalan `NEXT_PUBLIC_`, yang akan mengirimkannya ke setiap browser.

`.env.local`:

```bash
IKIFORM_SECRET_KEY=ik_sk_your_secret_key
SESSION_SECRET=the-secret-your-auth-already-uses
```

Di tab **Fields** formulir, tempel definisi ini. Pengguna melihat dua field pertama; server Anda mengatur empat lainnya. Karena ada field `email`, **Reply** di notifikasi langsung 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)

Coba skor `0` atau nama field yang tidak dikenal, dan preview menjawab dengan `422` yang sama dengan yang diterima action Anda.

## Bangun

### Baca pengguna di satu tempat

Next.js menyarankan data access layer: satu fungsi khusus server yang membaca dan memeriksa sesi, dipakai oleh setiap action. `cache` dari React membuat panggilan berulang dalam satu request hanya membaca cookie sekali. `decrypt` adalah apa pun yang sudah dimiliki kode sesi Anda; dengan library auth, kembalikan user dari library itu di sini.

`lib/user.ts`:

```ts
import "server-only";

import { cookies } from "next/headers";
import { cache } from "react";
import { decrypt } from "@/lib/session";

export interface User {
  id: string;
  email: string;
}

export const getUser = cache(async (): Promise<User | null> => {
  const token = (await cookies()).get("session")?.value;
  if (!token) return null;
  const session = await decrypt(token);
  return session?.userId ? { id: session.userId, email: session.email } : null;
});
```

### Server action

`app/feedback/actions.ts`:

```ts
"use server";

import { headers } from "next/headers";
import { getUser } from "@/lib/user";

export interface FeedbackState {
  status: "idle" | "sent" | "error";
  message: string;
  comment?: string;
}

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

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

export async function sendFeedback(_previous: FeedbackState, formData: FormData): Promise<FeedbackState> {
  const comment = String(formData.get("comment") ?? "").trim().slice(0, 4000);
  const user = await getUser();
  if (!user) return { status: "error", message: "Your session ended. Sign in again, then send it.", comment };

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

  const request = await headers();
  const screen = String(formData.get("screen") ?? "");

  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 ${process.env.IKIFORM_SECRET_KEY}`,
        Origin: request.get("origin") ?? "",
      },
      body: JSON.stringify({
        rating,
        comment,
        page: pageFrom(request.get("referer")),
        screen: SCREEN.test(screen) ? screen : "unknown",
        user_id: user.id,
        email: user.email,
        _gotcha: String(formData.get("_gotcha") ?? ""),
      }),
      signal: AbortSignal.timeout(8000),
    });
  } catch {
    return { status: "error", message: "We couldn't send it. Try again in a moment.", comment };
  }

  const result = await response.json().catch(() => null);
  if (result?.ok) return { status: "sent", message: "Thanks. We read every one." };
  return {
    status: "error",
    message: result?.error?.fields?.comment?.[0] ?? result?.error?.message ?? "We couldn't send it. Try again.",
    comment,
  };
}
```

- Pemeriksaan sesi dilakukan paling awal dan tidak melempar exception. Pengguna yang sesinya kedaluwarsa di tengah menulis komentar mendapat pesan dan tetap memegang komentarnya, bukan halaman error.
- `pageFrom` hanya menyimpan origin dan path dari `Referer`. Query string di aplikasi sering berisi ID dan token yang tidak Anda inginkan di inbox feedback.
- `screen` harus cocok dengan pola pendek mirip path. Selain itu disimpan sebagai `unknown`, sehingga kiriman rekayasa tidak bisa mengisi kolom dengan sampah.
- `Origin` meneruskan origin browser. Di produksi nilainya domain Anda; di komputer Anda nilainya `localhost`, yang membuat uji lokal gratis, seperti dijelaskan di bawah.
- `AbortSignal.timeout` mencegah tombol berputar selamanya saat jaringan lambat.

### Formulirnya

`app/feedback/feedback-form.tsx`:

```tsx
"use client";

import { useActionState } from "react";
import { type FeedbackState, sendFeedback } from "./actions";

const initial: FeedbackState = { status: "idle", message: "" };
const SCORES = ["Broken", "Frustrating", "Fine", "Good", "Great"];

export function FeedbackForm({ screen }: { screen: string }) {
  const [state, action, pending] = useActionState(sendFeedback, initial);

  if (state.status === "sent") return <p role="status">{state.message}</p>;

  return (
    <form action={action} aria-busy={pending}>
      <fieldset>
        <legend>How is this screen working for you?</legend>
        {SCORES.map((label, index) => (
          <label key={label}>
            <input type="radio" name="rating" value={index + 1} required /> {label}
          </label>
        ))}
      </fieldset>
      <label htmlFor="feedback-comment">What would make it better?</label>
      <textarea id="feedback-comment" name="comment" rows={4} maxLength={4000} defaultValue={state.comment} />
      <input type="hidden" name="screen" value={screen} />
      <input name="_gotcha" type="text" tabIndex={-1} autoComplete="off" hidden />
      {state.status === "error" ? <p role="alert">{state.message}</p> : null}
      <button disabled={pending}>{pending ? "Sending" : "Send feedback"}</button>
    </form>
  );
}
```

- Action langsung dipasang ke `action`. Next.js merendernya sebagai form post sungguhan, jadi pengguna yang mengklik sebelum JavaScript termuat tetap mengirim feedback-nya.
- `pending` dari `useActionState` menonaktifkan tombol selama action berjalan, yang mencegah pengiriman ganda.
- React mereset formulir setelah action. `defaultValue={state.comment}` mengembalikan komentar saat ada error, sehingga tidak ada yang mengetiknya dua kali.
- Label menamai skornya. `Broken` dan `Great` lebih bermakna daripada `1` dan `5`, dan nilai yang disimpan tetap angka.

### Taruh di sebuah layar

`app/(app)/projects/[id]/settings/page.tsx`:

```tsx
import { FeedbackForm } from "@/app/feedback/feedback-form";

export default function ProjectSettingsPage() {
  return (
    <main>
      <h1>Project settings</h1>
      <aside>
        <FeedbackForm screen="projects/[id]/settings" />
      </aside>
    </main>
  );
}
```

Teruskan path folder route sebagai `screen`. Halaman pengaturan setiap proyek kemudian berbagi satu nama, dan alamat lengkap dengan ID aslinya tetap ada di `page`.

## Honeypot, pemeriksaan manusia, dan batas

Input tersembunyi `_gotcha` diteruskan apa adanya. Ikiform menyimpan yang terisi sebagai spam. Pemeriksaan manusia tidak berjalan, karena kiriman dengan secret key melewatinya, dan itu tidak masalah di sini: action menolak siapa pun tanpa sesi, yang tidak dimiliki bot. Risiko yang tersisa adalah satu akun yang login mengirim terus-menerus. Rate limit Ikiform hanya melihat alamat server Anda, jadi pilih preset yang sesuai.

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 caching

Formulir tidak membaca apa pun saat render. Formulir tidak memanggil `cookies()` atau `headers()`, jadi tidak membuat halaman menjadi dinamis, dan dengan Cache Components aktif ia bisa berada di static shell tanpa boundary `<Suspense>`. Hanya action yang menyentuh request, dan action selalu berjalan per request. Jika halaman sudah membaca sesi untuk keperluan lain, tidak ada yang berubah.

## Deploy

| Host | Yang harus dilakukan |
| --- | --- |
| Vercel | Tambahkan `IKIFORM_SECRET_KEY` di **Environment Variables** proyek untuk Production dan Preview. Action berjalan sebagai function tanpa penyiapan tambahan. |
| Netlify | Netlify membangun Next.js dengan integrasinya sendiri. Tambahkan key di environment variable situs dengan scope Functions, lalu deploy ulang. |
| Cloudflare | Cloudflare menjalankan Next.js di Workers melalui integrasi OpenNext. Simpan key sebagai secret Worker, dan taruh di `.dev.vars` untuk preview lokal build Worker. |

Di balik proxy atau CDN yang mengubah host, Next.js menolak action yang `Origin`-nya tidak cocok. Daftarkan domain itu di `experimental.serverActions.allowedOrigins` di `next.config.ts`.

## Uji gratis di localhost

Workspace tanpa paket menerima 50 kiriman uji per bulan, tetapi hanya dari `localhost`, yang dibaca Ikiform dari header `Origin`. `fetch` di sisi server tidak mengirim `Origin` sendiri, jadi tanpa baris yang meneruskannya, setiap uji lokal akan kembali dengan `402 plan_required` dan ditahan. Dengan baris itu, `next dev` di `http://localhost:3000` dihitung sebagai uji lokal, dan kiriman produksi membawa domain asli Anda.

## Pastikan berfungsi

1. Jalankan `next dev`, login, buka pengaturan sebuah proyek, dan kirim skor 2 dengan komentar.
2. Buka **Submissions**. Jawabannya berisi `user_id`, `email`, `screen` bernilai `projects/[id]/settings`, dan `page` dengan ID proyek asli tanpa query string.
3. Logout di tab lain, lalu kirim lagi. Formulir menampilkan pesan login dengan komentar Anda masih di kotaknya.
4. Matikan JavaScript dan kirim sekali lagi. Halaman dimuat ulang dengan baris terima kasih.
5. Balas email notifikasi. Balasannya menuju alamat pengguna yang login.

## Hal yang sering menjebak

| Masalah | Yang harus dilakukan |
| --- | --- |
| `402 plan_required` di setiap uji lokal | Kiriman sampai ke Ikiform tanpa origin `localhost`. Periksa baris `Origin` di action, dan buka aplikasi di `localhost`, bukan alamat jaringan komputer Anda. |
| `page` kosong | `Referrer-Policy` yang ketat seperti `no-referrer` menghapus header itu, bahkan pada request same-origin. Gunakan `strict-origin-when-cross-origin`, nilai default-nya. |
| Failed to find Server Action, tepat setelah deploy | Sebuah tab masih menjalankan build sebelumnya, yang ID action-nya sudah tidak ada. Minta pengguna memuat ulang, dan jaga `NEXT_SERVER_ACTIONS_ENCRYPTION_KEY` tetap sama di setiap instance saat Anda self-host. |
| `403 forbidden` dari Ikiform | Key tidak punya `submissions:write`, milik workspace lain, atau sudah dicabut. Buat yang baru. |
| `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 melampirkan pengguna yang login ke feedback di Next.js?

Baca sesi di dalam server action, bukan di browser. Panggil helper sesi atau auth Anda di sana dan tambahkan ID serta email pengguna ke kiriman. Apa pun yang dikirim browser tentang identitas bisa diubah oleh pengguna.

### Bagaimana server action Next.js tahu feedback berasal dari halaman mana?

Server action adalah POST ke halaman yang merender formulir, jadi header Referer-nya berisi URL halaman itu. Simpan origin dan path-nya dan buang query string-nya.

### Apakah formulir feedback membuat halaman Next.js saya menjadi dinamis?

Tidak yang ini. Formulir tidak membaca cookie atau header saat dirender. Hanya action yang membaca sesi, dan action berjalan di setiap submit.

### 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.
- [Widget feedback React](https://ikiform.com/id/guides/feedback-form/react-feedback-widget.md): Tombol feedback mengambang untuk aplikasi React yang membuka panel kecil, mengirim layar, versi aplikasi, dan akun bersama setiap jawaban, dan tidak pernah kehilangan draf.
- [Formulir feedback React Router](https://ikiform.com/id/guides/feedback-form/react-router.md): Formulir feedback React Router 8: action di resource route membaca sesi cookie, fetcher mengirim dari layout, dan kiriman tanpa JS di-redirect kembali.
- [Formulir feedback TanStack Start](https://ikiform.com/id/guides/feedback-form/tanstack-start.md): Formulir feedback dalam aplikasi TanStack Start: server function POST dengan middleware sesi, route id sebagai konteks, dan halaman dibaca dari header request.
- [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.
