# Buat formulir feedback dalam aplikasi di TanStack Start

> Formulir feedback dalam aplikasi TanStack Start: server function POST dengan middleware sesi, route id sebagai konteks, dan halaman dibaca dari header request.

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

Tulis server function POST untuk feedback dan pasang function middleware yang membaca sesi dan menaruh pengguna di `context`. Function menambahkan pengguna itu, halaman dari `Referer` request, dan route id yang cocok ke rating, lalu meneruskannya ke backend formulir Anda dengan secret key. Mount formulir sekali di layout route untuk pengguna login dan formulir itu tahu sendiri sedang berada di layar mana.

[Tab TanStack Start di panduan utama](https://ikiform.com/id/guides/feedback-form.md#form) mengirim feedback halaman dari siapa pun, dengan halaman diatur di browser. Di dalam aplikasi dengan login Anda bisa lebih baik: server tahu siapa yang bertanya, dan router tahu layar mana yang terbuka. Penyiapan akun, key, dan allowed domains dibahas di [panduan dokumentasi server](https://ikiform.com/id/docs/guides/server.md).

## Siapa menyediakan apa

Di Start, server function adalah endpoint HTTP di bawah `/_serverFn/` yang dipanggil komponen Anda lewat klien yang dihasilkan. Apa pun di input-nya bisa ditulis ulang oleh siapa pun yang memanggilnya. Jadi input hanya membawa apa yang dipilih pengguna, dan semua hal tentang identitas berasal dari middleware yang berjalan di server sebelum handler.

| Nilai | Sumber | Kepercayaan |
| --- | --- | --- |
| `rating`, `comment` | `FormData` dari formulir | Kata-kata pengguna sendiri |
| `user_id`, `email` | `context.user`, diatur oleh middleware sesi | Dibaca dari cookie tersegel di server |
| `page` | `getRequestHeader("referer")` di dalam handler | Browser memanggil function dari halaman yang terbuka, jadi header berisi URL-nya |
| `screen` | `routeId` dari kecocokan terdalam, seperti `/_app/projects/$projectId/settings` | Label pengelompokan. Diperiksa terhadap pola sebelum disimpan |

Diagram: Bagaimana server function TanStack Start menambahkan pengguna dan halaman ke jawaban feedback

1. Formulir mengumpulkan skor dan komentar, menambahkan route id saat ini, lalu memanggil server function.
2. Di server, middleware membaca sesi dan meneruskan pengguna sebagai konteks.
3. Validator mengubah FormData menjadi objek bertipe dan membuang nama layar yang bentuknya salah.
4. Handler menolak jika pengguna tidak ada, lalu menambahkan pengguna dan halaman dari header Referer.
5. Handler mengirim ke Ikiform dengan secret key dan mengembalikan sent, atau error yang ditampilkan formulir.

Pengguna berasal dari cookie di server. Tidak ada isi data formulir yang bisa mengubah asal sebuah jawaban.

## Setup proyek

Struktur ini mengasumsikan routing berbasis file dengan layout `_app` tanpa path yang membungkus setiap layar untuk pengguna login. `session.ts` adalah helper iron-session dari panduan autentikasi Start; library sesi apa pun yang membaca cookie bekerja dengan cara yang sama. `findUser` di `users.ts` dan `getCurrentUserFn` di `server/user.ts` adalah pencarian pengguna milik Anda sendiri.

`Struktur proyek`:

```text
src/
  components/feedback-form.tsx
  lib/
    session.ts
    user-middleware.ts
    users.ts
  routes/
    _app.tsx
    _app/projects/$projectId/settings.tsx
  server/
    feedback.ts
    user.ts
.env
```

Start membaca variabel tanpa awalan dari `process.env` hanya di server. Apa pun dengan awalan `VITE_` di-bundle untuk browser, jadi key tidak boleh memakainya. Baca di dalam handler, bukan di bagian atas modul.

`.env`:

```bash
IKIFORM_SECRET_KEY=ik_sk_your_secret_key
SESSION_SECRET=at-least-32-characters-from-your-auth-setup
```

Tempel definisi field di tab **Fields** formulir. Formulir menampilkan dua di antaranya; server function mengisi empat lainnya, 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 `rating: 6` atau field yang tidak ada di definisi, dan preview menampilkan `422` yang akan diterima server function Anda.

## Bangun

### Middleware sesi

Function middleware membungkus setiap server function yang mencantumkannya. Apa pun yang masuk ke `next({ context })` tiba dengan tipe di handler. Middleware meneruskan `null` alih-alih melempar exception, sehingga handler bisa menjawab dengan pesan yang ditampilkan formulir.

`src/lib/user-middleware.ts`:

```ts
import { createMiddleware } from "@tanstack/react-start";
import { getAppSession } from "./session";
import { findUser } from "./users";

export const userMiddleware = createMiddleware({ type: "function" }).server(async ({ next }) => {
  const session = await getAppSession();
  const user = session.userId ? await findUser(session.userId) : null;
  return next({ context: { user: user ? { id: user.id, email: user.email } : null } });
});
```

### Server function

`src/server/feedback.ts`:

```ts
import { createServerFn } from "@tanstack/react-start";
import { getRequestHeader } from "@tanstack/react-start/server";
import { userMiddleware } from "../lib/user-middleware";

export type FeedbackResult = { status: "sent" } | { status: "error"; message: string };

const ROUTE_ID = /^\/[A-Za-z0-9/$_.-]{0,119}$/;

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

export const sendFeedback = createServerFn({ method: "POST" })
  .middleware([userMiddleware])
  .validator((data: unknown) => {
    if (!(data instanceof FormData)) throw new Error("Expected form data");
    const rating = Number(data.get("rating"));
    const screen = String(data.get("screen") ?? "");
    return {
      rating: Number.isInteger(rating) && rating >= 1 && rating <= 5 ? rating : null,
      comment: String(data.get("comment") ?? "").trim().slice(0, 4000),
      screen: ROUTE_ID.test(screen) ? screen : "unknown",
      trap: String(data.get("_gotcha") ?? ""),
    };
  })
  .handler(async ({ data, context }): Promise<FeedbackResult> => {
    if (!context.user) return { status: "error", message: "You're signed out. Sign in and send it again." };
    if (data.rating === null) return { status: "error", message: "Choose a score first." };

    const origin = getRequestHeader("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 ${process.env.IKIFORM_SECRET_KEY}`,
          ...(origin ? { Origin: origin } : {}),
        },
        body: JSON.stringify({
          rating: data.rating,
          comment: data.comment,
          page: pageFrom(getRequestHeader("referer")),
          screen: data.screen,
          user_id: context.user.id,
          email: context.user.email,
          _gotcha: data.trap,
        }),
        signal: AbortSignal.timeout(8000),
      });
    } catch {
      return { status: "error", message: "Feedback is unreachable right now. Try again shortly." };
    }

    const result = await response.json().catch(() => null);
    if (result?.ok) return { status: "sent" };
    return { status: "error", message: result?.error?.message ?? "That didn't go through. Try again." };
  });
```

- `.validator` menerima `FormData` mentah dan mengembalikan objek biasa. Skor yang buruk menjadi `null` alih-alih error yang dilempar, yang akan sampai ke klien sebagai kegagalan umum.
- `getRequestHeader` membaca request yang masuk dari mana saja di handler, tanpa objek request yang diteruskan ke sana kemari.
- `Origin` diteruskan dari panggilan browser. Nilai ini menentukan apakah Ikiform memperlakukan kiriman sebagai uji lokal, dijelaskan di bawah.
- Hanya ID dan email yang masuk ke konteks, bukan seluruh record pengguna. Apa pun yang dikembalikan handler diserialisasi ke browser.

### Formulirnya

`src/components/feedback-form.tsx`:

```tsx
import { useMatches } from "@tanstack/react-router";
import { useServerFn } from "@tanstack/react-start";
import { type FormEvent, useState } from "react";
import { type FeedbackResult, sendFeedback } from "../server/feedback";

const SCORES = ["Badly", "Poorly", "Okay", "Well", "Very well"];

export function FeedbackForm() {
  const send = useServerFn(sendFeedback);
  const screen = useMatches({ select: (matches) => matches.at(-1)?.routeId ?? "unknown" });
  const [result, setResult] = useState<FeedbackResult | null>(null);
  const [pending, setPending] = useState(false);

  async function onSubmit(event: FormEvent<HTMLFormElement>) {
    event.preventDefault();
    const data = new FormData(event.currentTarget);
    data.set("screen", screen);
    setPending(true);
    try {
      setResult(await send({ data }));
    } catch {
      setResult({ status: "error", message: "We lost the connection. Try again." });
    } finally {
      setPending(false);
    }
  }

  if (result?.status === "sent") return <p role="status">Thanks. We read every one.</p>;

  return (
    <form method="post" onSubmit={onSubmit} 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} />
      <input name="_gotcha" type="text" tabIndex={-1} autoComplete="off" hidden />
      {result?.status === "error" ? <p role="alert">{result.message}</p> : null}
      <button disabled={pending}>{pending ? "Sending" : "Send feedback"}</button>
    </form>
  );
}
```

- `useMatches` dengan `select` hanya mengembalikan `routeId` dari kecocokan terakhir, jadi komponen dirender ulang saat layar berubah, bukan di setiap pembaruan router.
- `onSubmit` menghentikan kiriman native dan memanggil function. Tidak ada yang mereset formulir, jadi setelah error komentarnya masih ada di kotak.
- `pending` menonaktifkan tombol selama panggilan berlangsung, yang mencegah pengiriman ganda.

### Mount di layout route

`src/routes/_app.tsx`:

```tsx
import { createFileRoute, Outlet, redirect } from "@tanstack/react-router";
import { FeedbackForm } from "../components/feedback-form";
import { getCurrentUserFn } from "../server/user";

export const Route = createFileRoute("/_app")({
  beforeLoad: async () => {
    const user = await getCurrentUserFn();
    if (!user) throw redirect({ to: "/login" });
    return { user };
  },
  component: () => (
    <>
      <Outlet />
      <aside>
        <FeedbackForm />
      </aside>
    </>
  ),
});
```

`beforeLoad` menjauhkan pengguna yang logout dari layar-layar itu, tetapi bukan itu yang melindungi feedback: siapa pun bisa memanggil `/_serverFn/` secara langsung, itulah sebabnya middleware memeriksa lagi. Route id menyertakan layout tanpa path, karena itu ada awalan `/_app` di `screen`. Hapus sebelum disimpan jika Anda lebih suka nama yang lebih pendek.

## Honeypot, pemeriksaan manusia, dan batas

Input `_gotcha` melewati validator tanpa diubah, dan Ikiform mencatat yang terisi sebagai spam. Pemeriksaan manusianya dilewati untuk kiriman dengan secret key, dan itu tidak masalah di balik sesi: bot tidak punya sesi. Yang tersisa adalah akun yang login mengirim terus-menerus, dan Ikiform menghitung semuanya terhadap 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.

## SSR, mode SPA, dan prerendering

Formulir bekerja sama baik dengan SSR penuh maupun dengan `spa: { enabled: true }` di opsi `tanstackStart()`. Di mode SPA, hanya root shell yang di-prerender dan layar dirender di browser, tetapi server function tetap butuh server yang berjalan: jika host menulis ulang setiap path ke shell, tambahkan `/_serverFn/*` ke path yang diteruskannya. Jangan prerender layar untuk pengguna login; tidak ada pengguna saat build.

Dokumentasi Start juga menyebut properti `.url` untuk memanggil server function dari formulir HTML biasa. Halaman ini tidak memakainya. Layar untuk pengguna login sudah ter-hydrate sebelum siapa pun mengetik komentar, dan kiriman tanpa script butuh function yang menjawab dengan redirect alih-alih data. Anggap formulir ini butuh JavaScript.

## Deploy

| Host | Setup |
| --- | --- |
| Vercel | Tambahkan `nitro()` dari `nitro/vite` ke plugin Vite, deploy, dan tambahkan `IKIFORM_SECRET_KEY` ke environment variable proyek. |
| Netlify | Tambahkan `netlify()` dari `@netlify/vite-plugin-tanstack-start` dan atur key di environment variable situs. |
| Cloudflare | Tambahkan `cloudflare({ viteEnvironment: { name: "ssr" } })` dari `@cloudflare/vite-plugin`, aktifkan `nodejs_compat`, dan simpan key dengan `wrangler secret put`. Dengan compatibility date 2025-04-01 atau setelahnya, flag itu mengisi `process.env` dengan secret Anda. |

## Uji gratis dari localhost

Tanpa paket, workspace menerima 50 kiriman uji per bulan saat header `Origin` menyebut `localhost`. `fetch` milik server Anda tidak punya origin, jadi kiriman tanpa header yang diteruskan dijawab `402 plan_required` dan ditahan. Dengan header itu, `vite dev` di `http://localhost:3000` meneruskan origin browser dan setiap jawaban dihitung sebagai uji. Di produksi, baris yang sama mengirim domain aplikasi Anda.

## Pastikan berfungsi

1. Jalankan `vite dev`, login, buka pengaturan sebuah proyek, dan kirim nilai 2 dengan komentar.
2. Di **Submissions**, jawabannya berisi `user_id`, `email`, `screen` bernilai route id, dan `page` dengan ID proyek asli.
3. Logout di tab lain lalu kirim lagi. Formulir menampilkan pesan logout dan tetap menyimpan komentar.
4. Panggil function dari konsol browser dengan ID pengguna palsu di data formulir. `user_id` yang disimpan tetap milik sesi.
5. Balas email notifikasi. Balasannya menuju pengguna yang login.

## Hal yang sering menjebak

| Masalah | Yang harus dilakukan |
| --- | --- |
| Server function kehilangan pemeriksaan CSRF setelah Anda menambahkan `src/start.ts` | Tanpa `src/start.ts`, Start menambahkan middleware CSRF untuk server function dengan sendirinya. Begitu Anda mendefinisikannya, daftarkan `createCsrfMiddleware` di sana sendiri, difilter untuk server function. |
| `402 plan_required` pada uji lokal | Header `Origin` tidak sampai ke Ikiform. Periksa baris penerusannya, dan buka aplikasi di `localhost`, bukan alamat LAN. |
| `IKIFORM_SECRET_KEY` bernilai undefined di Cloudflare | Workers mengisi `process.env` hanya jika `nodejs_compat` aktif dan compatibility date 2025-04-01 atau setelahnya. Periksa keduanya di `wrangler.jsonc`, atau baca key dari `env` di `cloudflare:workers`. |
| `screen` selalu `unknown` | Route id gagal memenuhi pola, sering karena karakter seperti `{` dari parameter opsional. Perluas polanya agar cocok dengan route Anda. |
| `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 saat ini di dalam server function TanStack Start?

Tulis function middleware yang membaca sesi dan memanggil next dengan pengguna di konteks, lalu cantumkan di middleware server function. Handler membaca context.user, lengkap dengan tipe, di server.

### Bisakah server function TanStack Start membaca header request?

Bisa. getRequestHeader dan getRequest dari @tanstack/react-start/server membaca request yang masuk di dalam handler, jadi Anda bisa mengambil halaman dari Referer tanpa mengirimnya dari browser.

### Apakah formulir feedback bekerja di mode SPA TanStack Start?

Ya, selama ada server yang menjalankan server function Anda dan host tidak menulis ulang path /_serverFn/ ke shell SPA.

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