# Buat formulir pendaftaran newsletter di TanStack Start

> Formulir newsletter TanStack Start: server function untuk pendaftaran di dalam halaman, server route sebagai cadangan tanpa JavaScript, dan pendaftaran asli dikirim ke beehiiv.

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

Tulis logika pendaftaran sekali di modul server, lalu panggil dari dua tempat: function POST `createServerFn` yang dipanggil formulir di halaman, dan server route yang dikirimi formulir yang sama saat JavaScript belum termuat. Keduanya memeriksa kotak persetujuan dan meneruskan pendaftaran ke backend formulir Anda dengan secret key. Server route kedua menerima webhook bertanda tangan dari backend dan membuat subscription di beehiiv.

[Tab TanStack Start di panduan utama](https://ikiform.com/id/guides/newsletter-signup.md#form) terdiri dari satu server function dan satu komponen. Itu mencakup pembaca yang JavaScript-nya sudah termuat. Halaman ini menambahkan apa yang dibutuhkan situs yang Anda bagikan secara luas: jalur untuk pembaca yang mengirim sebelum hydration, halaman hasil untuk jalur itu, dan route webhook yang meneruskan pendaftaran asli ke beehiiv. Penyiapan produk ada di [panduan dokumentasi sisi server](https://ikiform.com/id/docs/guides/server.md).

## Apa yang diubah TanStack Start

Server function dipanggil lewat request RPC yang dibuat klien, jadi formulir yang hanya mengenal `onSubmit` tidak melakukan apa-apa sampai bundle berjalan. Dokumentasi TanStack menyebut properti `.url` untuk mengirim formulir ke server function tanpa JavaScript, tetapi tidak mendokumentasikan bagaimana respons itu dirender. Server route lebih sederhana: menerima form POST biasa dan mengembalikan redirect biasa. Jadi formulir mendapat `action` sungguhan selain `onSubmit`-nya:

Diagram: Bagaimana satu formulir TanStack Start mencapai kode pendaftaran yang sama dengan atau tanpa JavaScript

1. Pembaca mengirim formulir. Apakah JavaScript sudah termuat menentukan jalurnya.
2. Sudah ter-hydrate: onSubmit menghentikan kiriman dan memanggil server function dengan data formulir.
3. Server function menjalankan forwardSignup, yang memeriksa persetujuan dan mengirim ke Ikiform, lalu mengembalikan hasilnya ke komponen.
4. Belum ter-hydrate: browser mengirim ke server route /api/newsletter, yang menjalankan forwardSignup yang sama.
5. Route menjawab 303 dengan status atau kode error, dan halaman /newsletter menampilkan pesan yang sesuai.

forwardSignup adalah satu-satunya kode yang berkomunikasi dengan Ikiform, jadi kedua jalur memvalidasi dan melaporkan error dengan cara yang sama.

## Setup proyek

`Struktur proyek`:

```text
src/
  server/newsletter.ts
  components/NewsletterForm.tsx
  routes/
    __root.tsx
    newsletter.tsx
    api/newsletter.ts
    api/ikiform.ts
.env
```

Kode server membaca secret dari `process.env`. Jangan pernah memberinya awalan `VITE_`: Vite memasukkan variabel `VITE_` ke bundle klien. Buat key Ikiform di **API keys** dengan scope `submissions:write` dan tempel [definisi field dari pilar](https://ikiform.com/id/guides/newsletter-signup.md#fields) di tab **Fields** formulir.

`.env`:

```bash
IKIFORM_SECRET_KEY=ik_sk_your_secret_key
IKIFORM_WEBHOOK_SECRET=whsec_your_webhook_secret
BEEHIIV_API_KEY=your_beehiiv_api_key
BEEHIIV_PUBLICATION_ID=pub_00000000-0000-0000-0000-000000000000
```

## Bangun

### Modul pendaftaran

`src/server/newsletter.ts`:

```ts
export interface Signup {
  email: string;
  first_name?: string;
  consent: boolean;
  source: string;
  _gotcha: string;
}

export type Outcome = { ok: true } | { ok: false; field: "email" | "consent" | "failed"; message: string };

const PLACEMENTS = new Set(["footer", "post", "newsletter-page"]);

export function readSignup(form: FormData): Signup {
  const source = String(form.get("source") ?? "");
  return {
    email: String(form.get("email") ?? "").trim(),
    first_name: String(form.get("first_name") ?? "").trim().slice(0, 80) || undefined,
    consent: form.get("consent") === "true",
    source: PLACEMENTS.has(source) ? source : "footer",
    _gotcha: String(form.get("_gotcha") ?? ""),
  };
}

export async function forwardSignup(signup: Signup, origin: string | undefined): Promise<Outcome> {
  if (!signup.consent) {
    return { ok: false, field: "consent", message: "Tick the box so we know you want the emails." };
  }
  const 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({ ...signup, consent: true }),
    signal: AbortSignal.timeout(8000),
  }).catch(() => null);

  const result = await response?.json().catch(() => null);
  if (result?.ok) return { ok: true };
  const email = result?.error?.fields?.email?.[0];
  if (email) return { ok: false, field: "email", message: email };
  return { ok: false, field: "failed", message: result?.error?.message ?? "We couldn't reach the list. Try again." };
}
```

- `readSignup` mengubah `FormData` menjadi objek biasa. Kedua pintu masuk memakainya, jadi nama field hanya ada di satu tempat.
- `source` harus salah satu dari tiga penempatan, jadi kiriman rekayasa tidak bisa mengarang yang baru.
- Baris `Origin` meneruskan origin browser, karena Ikiform mengenali uji `localhost` gratis dari header itu dan `fetch` milik server tidak mengirimnya.
- Setiap kegagalan mengembalikan `Outcome` alih-alih melempar exception, jadi komponen dan route sama-sama bisa menampilkannya.

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

### Server function dan formulir

`src/components/NewsletterForm.tsx`:

```tsx
import { createServerFn, useServerFn } from "@tanstack/react-start";
import { getRequestHeader } from "@tanstack/react-start/server";
import { type FormEvent, useState } from "react";
import { forwardSignup, type Outcome, readSignup } from "../server/newsletter";

const subscribe = createServerFn({ method: "POST" })
  .validator((data: unknown) => {
    if (!(data instanceof FormData)) throw new Error("Expected form data");
    return readSignup(data);
  })
  .handler(({ data }) => forwardSignup(data, getRequestHeader("origin")));

export function NewsletterForm({ source = "footer" }: { source?: string }) {
  const send = useServerFn(subscribe);
  const [outcome, setOutcome] = useState<Outcome | null>(null);
  const [pending, setPending] = useState(false);

  async function onSubmit(event: FormEvent<HTMLFormElement>) {
    event.preventDefault();
    const data = new FormData(event.currentTarget);
    setPending(true);
    try {
      setOutcome(await send({ data }));
    } catch {
      setOutcome({ ok: false, field: "failed", message: "Couldn't subscribe. Try again." });
    } finally {
      setPending(false);
    }
  }

  if (outcome?.ok) return <p role="status">Almost done. Open the email we sent you and confirm your address.</p>;
  const error = outcome?.ok === false ? outcome : null;

  return (
    <form method="post" action="/api/newsletter" onSubmit={onSubmit}>
      <label>
        Email
        <input name="email" type="email" required autoComplete="email" aria-invalid={error?.field === "email"} />
      </label>
      <label>
        First name (optional)
        <input name="first_name" maxLength={80} autoComplete="given-name" />
      </label>
      <label>
        <input name="consent" type="checkbox" value="true" required aria-invalid={error?.field === "consent"} />
        Email me the newsletter. I can unsubscribe at any time.
      </label>
      <input type="hidden" name="source" value={source} />
      <input name="_gotcha" type="text" tabIndex={-1} autoComplete="off" hidden />
      {error ? <p role="alert">{error.message}</p> : null}
      <button disabled={pending}>{pending ? "Subscribing" : "Subscribe"}</button>
    </form>
  );
}
```

- `.validator` menerima apa pun yang dikirim klien. Memeriksa `FormData` di sana berarti panggilan JSON rekayasa gagal sebelum handler berjalan.
- `getRequestHeader` berasal dari `@tanstack/react-start/server` dan membaca request yang membawa panggilan RPC, yang dikirim browser dengan origin halaman Anda.
- `FormData` disusun sebelum `await` pertama, selagi `event.currentTarget` masih menunjuk ke formulir.
- `useServerFn` membuat panggilan mengikuti redirect dan error melalui router, yang penting jika nanti Anda melempar `redirect()` dari handler.

### Route dan halaman tanpa JavaScript

`src/routes/api/newsletter.ts`:

```ts
import { createFileRoute } from "@tanstack/react-router";
import { forwardSignup, readSignup } from "../../server/newsletter";

export const Route = createFileRoute("/api/newsletter")({
  server: {
    handlers: {
      POST: async ({ request }) => {
        const signup = readSignup(await request.formData());
        const outcome = await forwardSignup(signup, request.headers.get("origin") ?? undefined);
        const target = new URL("/newsletter", request.url);
        target.searchParams.set(outcome.ok ? "status" : "error", outcome.ok ? "subscribed" : outcome.field);
        return Response.redirect(target, 303);
      },
    },
  },
});
```

`src/routes/newsletter.tsx`:

```tsx
import { createFileRoute } from "@tanstack/react-router";
import { NewsletterForm } from "../components/NewsletterForm";

const MESSAGES = {
  consent: "Tick the box so we know you want the emails.",
  email: "Check the email address and try again.",
  failed: "We couldn't reach the list. Try again.",
};

export const Route = createFileRoute("/newsletter")({
  validateSearch: (search: Record<string, unknown>) => ({
    status: search.status === "subscribed" ? ("subscribed" as const) : undefined,
    error: typeof search.error === "string" && search.error in MESSAGES ? (search.error as keyof typeof MESSAGES) : undefined,
  }),
  component: NewsletterPage,
});

function NewsletterPage() {
  const { status, error } = Route.useSearch();
  if (status === "subscribed") return <p role="status">Almost done. Open the email we sent you and confirm your address.</p>;
  return (
    <main>
      <h1>The newsletter</h1>
      {error ? <p role="alert">{MESSAGES[error]}</p> : null}
      <NewsletterForm source="newsletter-page" />
    </main>
  );
}
```

Route me-redirect dengan `303`, jadi memuat ulang halaman hasil tidak mengirim formulir lagi. Route meneruskan kode error, tidak pernah pesan Ikiform, dan `validateSearch` membuang apa pun di luar tiga kode itu, jadi tidak ada yang bisa menaruh teksnya sendiri di halaman Anda lewat tautan.

## Teruskan subscriber ke beehiiv

Server route ini menerima webhook bertanda tangan dari Ikiform dan membuat subscription beehiiv dengan `double_opt_override: "on"`, sehingga beehiiv mengirim email konfirmasi. Route membaca body dengan `request.text()` sebelum hal lain, karena tanda tangan mencakup byte persisnya. [Panduan beehiiv](https://ikiform.com/id/guides/newsletter-signup/beehiiv.md) menambahkan nama depan, referrer, dan header rate limit beehiiv.

`src/routes/api/ikiform.ts`:

```ts
import { createFileRoute } from "@tanstack/react-router";
import { verifyWebhook, WebhookVerificationError } from "@ikiform/sdk/webhooks";

export const Route = createFileRoute("/api/ikiform")({
  server: {
    handlers: {
      POST: async ({ request }) => {
        let event;
        try {
          event = await verifyWebhook({
            payload: await request.text(),
            headers: request.headers,
            secret: process.env.IKIFORM_WEBHOOK_SECRET ?? "",
          });
        } catch (error) {
          if (error instanceof WebhookVerificationError) return new Response(error.reason, { status: 400 });
          throw error;
        }
        if (event.type !== "submission.created") return new Response(null, { status: 204 });

        const { email, consent, source } = event.data.fields;
        if (consent !== true || typeof email !== "string") return new Response("Skipped", { status: 200 });

        const answer = await fetch(
          `https://api.beehiiv.com/v2/publications/${process.env.BEEHIIV_PUBLICATION_ID}/subscriptions`,
          {
            method: "POST",
            headers: { Authorization: `Bearer ${process.env.BEEHIIV_API_KEY}`, "Content-Type": "application/json" },
            body: JSON.stringify({
              email,
              double_opt_override: "on",
              reactivate_existing: false,
              utm_source: typeof source === "string" ? source : "website",
              utm_medium: "signup_form",
            }),
          },
        );
        if (answer.status === 429) return new Response("beehiiv rate limit", { status: 503, headers: { "retry-after": "60" } });
        if (!answer.ok) return new Response(`beehiiv answered ${answer.status}`, { status: 502 });
        return new Response(null, { status: 204 });
      },
    },
  },
});
```

Tambahkan `https://your-site.com/api/ikiform` di **Webhooks**, pilih formulir newsletter, dan salin secret `whsec_` ke `IKIFORM_WEBHOOK_SECRET`.

## Honeypot dan pemeriksaan manusia

Input `_gotcha` ikut bersama pendaftaran, dan Ikiform menyimpan yang terisi sebagai spam dengan jawaban sukses dan tanpa webhook, jadi beehiiv tidak pernah melihatnya. Secret key melewati pemeriksaan Turnstile Ikiform. Jika bot lolos dari honeypot dan kotak wajib, verifikasi token Turnstile di `forwardSignup`, seperti yang ditunjukkan [panduan Turnstile](https://ikiform.com/id/guides/spam-protection/cloudflare-turnstile.md), agar kedua pintu masuk mendapatkannya.

## Rendering

Start merender route di server secara default, jadi HTML formulir, termasuk `action`, sudah ada di respons pertama. Jika Anda melakukan prerender halaman sebagai file statis, formulir tetap bekerja di sana: server function dan kedua server route berjalan on demand di host.

## Deploy

| Host | Yang harus dilakukan |
| --- | --- |
| Vercel | Tambahkan Nitro: instal `nitro` dan taruh `nitro()` dari `nitro/vite` di plugin Vite. Atur keempat variabel di **Environment Variables**. |
| Netlify | Tambahkan `@netlify/vite-plugin-tanstack-start` dan taruh `netlify()` di plugin Vite. Atur variabel di pengaturan environment situs. |
| Cloudflare | Tambahkan `@cloudflare/vite-plugin` paling awal di daftar plugin, dengan `wrangler.jsonc` yang mengaktifkan `nodejs_compat`. Dengan flag itu dan compatibility date terbaru, Workers menyediakan variabel dan secret di `process.env`. Gunakan domain kustom untuk URL webhook; Ikiform menolak `workers.dev`. |

## Uji gratis di localhost

Workspace tanpa paket menerima 50 kiriman uji per bulan dari `localhost`, dan `Origin` yang diteruskan membuat setiap pendaftaran dari dev server dihitung sebagai salah satunya. Uji kedua jalur: sekali secara normal, dan sekali dengan JavaScript dimatikan di dev tools. Ikiform tidak akan mengirim webhook ke `localhost`, jadi uji `/api/ikiform` lewat tunnel dengan URL HTTPS.

## Pastikan berfungsi

1. Jalankan dev server dan berlangganan dari footer. Formulir diganti dengan baris periksa-inbox dan URL tidak berubah.
2. Matikan JavaScript dan berlangganan lagi. Anda mendarat di `/newsletter?status=subscribed`.
3. Dengan JavaScript masih mati, kirim dengan `required` dihapus dan kotak tidak dicentang. Anda mendarat di `/newsletter?error=consent` dengan pesan persetujuan.
4. Buka **Submissions** di dashboard. Kiriman baru ada di sana dengan setiap field yang Anda kirim.
5. Jika Anda menambahkan webhook, buka endpoint-nya di **Webhooks**. Pengiriman menampilkan `succeeded` dengan jawaban dari server Anda.
6. Buka beehiiv. Subscription ada di sana, pending sampai Anda mengonfirmasi, dan email konfirmasi ada di inbox Anda.

## Hal yang sering menjebak

| Masalah | Yang harus dilakukan |
| --- | --- |
| Secret key bernilai `undefined` di produksi | Host tidak mendapat variabelnya, atau variabel dibaca di kode yang berjalan di browser. Baca secret hanya di dalam server function dan server route. |
| Halaman error JSON setelah mengirim tanpa JavaScript | `action` formulir mengarah ke server function, bukan server route. Kirim ke `/api/newsletter`. |
| `402 plan_required` pada uji lokal | Kiriman sampai ke Ikiform tanpa origin `localhost`. Pastikan `forwardSignup` menerima origin dari kedua pemanggil. |
| Pengiriman webhook gagal dengan `400` | Secret tidak cocok dengan milik endpoint, atau ada yang mem-parsing body sebelum `request.text()`. Baca teks mentah lebih dulu. |
| Kiriman ada di dashboard tetapi tidak ada email atau webhook yang datang | Kiriman itu disimpan sebagai spam. Spam tidak mengirim apa pun. Buka di filter **Spam** untuk melihat alasannya, dan tandai sebagai bukan spam untuk mengirim email dan webhook-nya. |

## Pertanyaan

### Apakah server function TanStack Start bekerja tanpa JavaScript?

Dokumentasi TanStack menyebut properti .url untuk mengirim formulir ke server function, tetapi server route adalah cadangan yang lebih sederhana: menerima form POST biasa lalu me-redirect. Halaman ini memakai keduanya, dengan satu modul bersama di belakangnya.

### Di mana secret disimpan di TanStack Start?

Di environment variable khusus server yang dibaca dengan process.env di dalam server function dan server route. Variabel dengan awalan VITE_ masuk ke bundle browser.

### Mengapa tidak menambahkan subscriber ke beehiiv di dalam server function?

Ikiform menjawab spam dengan sukses yang sama seperti pendaftaran asli. Webhook-nya hanya dikirim untuk pendaftaran yang bukan spam, jadi membuat subscription dari webhook menjauhkan bot dari daftar Anda.

### 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 pendaftaran newsletter](https://ikiform.com/id/guides/newsletter-signup.md): Buat formulir pendaftaran newsletter dengan markup Anda sendiri, catat persetujuan, blokir pendaftaran bot, dan teruskan setiap subscriber ke Kit, MailerLite, beehiiv, atau alat apa pun.
- [beehiiv](https://ikiform.com/id/guides/newsletter-signup/beehiiv.md): Hubungkan formulir pendaftaran Anda sendiri ke beehiiv: relay membuat setiap subscription dengan double opt-in aktif, penempatan sebagai UTM source, dan halaman sebagai referrer.
- [Double opt-in](https://ikiform.com/id/guides/newsletter-signup/double-opt-in.md): Ikiform tidak mengirim email konfirmasi, jadi double opt-in berjalan di alat email Anda: relay menambahkan setiap pendaftaran sebagai belum terkonfirmasi dan alat itu mengirim tautannya.
- [Formulir newsletter React Router](https://ikiform.com/id/guides/newsletter-signup/react-router.md): Formulir newsletter di footer React Router: useFetcher mengirim ke route /newsletter yang juga bekerja tanpa JavaScript, dan resource route mengisi MailerLite.
- [Situs statis](https://ikiform.com/id/guides/newsletter-signup/static-site.md): Tambahkan formulir pendaftaran newsletter ke situs statis Hugo, Jekyll, Eleventy, atau Astro, jauhkan API key alat email dari halaman, dan lakukan konfirmasi di alat itu.
- [Kit (ConvertKit)](https://ikiform.com/id/guides/newsletter-signup/kit.md): Hubungkan formulir newsletter Anda sendiri ke Kit dengan relay yang membuat subscriber, menambahkannya ke formulir Kit untuk incentive email, dan memberi tag penempatannya.
