# Event RSVP form: HTML, React and Next.js code

> An RSVP form for meetups, workshops and parties: attendance, guest count and dietary needs, with server-side limits on how many guests each person can bring.

Updated 2026-10-24 · https://ikiform.com/forms/event-rsvp

## Copy the code

Every version below is generated from the same field definition. Replace `frm_xxxxxxxxxxxxxxxx` with your form id, or enter it in the **Form ID** box and the code updates.

**HTML** (`event-rsvp.html`)

```html
<form action="https://ikiform.com/f/frm_xxxxxxxxxxxxxxxx" method="post">
  <label for="name">Name</label>
  <input id="name" name="name" type="text" maxlength="120" required />
  <label for="email">Email</label>
  <input id="email" name="email" type="email" autocomplete="email" required />
  <fieldset>
    <legend>Can you make it?</legend>
    <label><input type="radio" name="attending" value="yes" required /> Yes</label>
    <label><input type="radio" name="attending" value="no" required /> No</label>
  </fieldset>
  <label for="guests">Extra guests</label>
  <input id="guests" name="guests" type="number" min="0" max="3" step="1" aria-describedby="guests-help" />
  <p id="guests-help">Not counting you. Up to 3.</p>
  <label for="dietary">Dietary needs</label>
  <input id="dietary" name="dietary" type="text" maxlength="200" />
  <input name="_gotcha" type="text" tabindex="-1" autocomplete="off" hidden />
  <button>Send RSVP</button>
</form>
```

**React** (`RsvpForm.tsx`)

```tsx
import { submit } from "@ikiform/sdk";
import { type FormEvent, useState } from "react";

const FORM_ID = "frm_xxxxxxxxxxxxxxxx";

type Status = "idle" | "sending" | "sent" | "error";

export function RsvpForm() {
  const [status, setStatus] = useState<Status>("idle");
  const [errors, setErrors] = useState<Record<string, string[]>>({});
  const [message, setMessage] = useState("");

  async function onSubmit(event: FormEvent<HTMLFormElement>) {
    event.preventDefault();
    setStatus("sending");
    setErrors({});
    const result = await submit(FORM_ID, new FormData(event.currentTarget));
    if (result.ok) return setStatus("sent");
    setErrors(result.error.fields ?? {});
    setMessage(result.error.message);
    setStatus("error");
  }

  if (status === "sent") return <p>Thanks. Your RSVP is in.</p>;

  return (
    <form onSubmit={onSubmit}>
      <label htmlFor="name">Name</label>
      <input id="name" name="name" type="text" maxLength={120} required />
      {errors.name ? <p>{errors.name[0]}</p> : null}
      <label htmlFor="email">Email</label>
      <input id="email" name="email" type="email" autoComplete="email" required />
      {errors.email ? <p>{errors.email[0]}</p> : null}
      <fieldset>
        <legend>Can you make it?</legend>
        <label><input type="radio" name="attending" value="yes" required /> Yes</label>
        <label><input type="radio" name="attending" value="no" required /> No</label>
      </fieldset>
      {errors.attending ? <p>{errors.attending[0]}</p> : null}
      <label htmlFor="guests">Extra guests</label>
      <input id="guests" name="guests" type="number" min={0} max={3} step={1} aria-describedby="guests-help" />
      <p id="guests-help">Not counting you. Up to 3.</p>
      {errors.guests ? <p>{errors.guests[0]}</p> : null}
      <label htmlFor="dietary">Dietary needs</label>
      <input id="dietary" name="dietary" type="text" maxLength={200} />
      {errors.dietary ? <p>{errors.dietary[0]}</p> : null}
      <input name="_gotcha" type="text" tabIndex={-1} autoComplete="off" hidden />
      {status === "error" && !Object.keys(errors).length ? <p role="alert">{message}</p> : null}
      <button disabled={status === "sending"}>{status === "sending" ? "Sending" : "Send RSVP"}</button>
    </form>
  );
}
```

**JavaScript** (`event-rsvp.js`)

```js
const form = document.querySelector('form[action="https://ikiform.com/f/frm_xxxxxxxxxxxxxxxx"]');
const status = document.createElement("p");
status.setAttribute("role", "alert");
form.append(status);

const labels = {
  name: "Name",
  email: "Email",
  attending: "Can you make it?",
  guests: "Extra guests",
  dietary: "Dietary needs",
};

form.addEventListener("submit", async (event) => {
  event.preventDefault();
  const button = form.querySelector("button");
  button.disabled = true;
  status.textContent = "";
  try {
    const response = await fetch(form.action, {
      method: "POST",
      headers: { Accept: "application/json" },
      body: new FormData(form),
    });
    const result = await response.json();
    if (result.ok) {
      form.replaceWith("Thanks. Your RSVP is in.");
      return;
    }
    const fields = Object.entries(result.error.fields ?? {});
    status.textContent = fields.length
      ? fields.map(([id, messages]) => `${labels[id] ?? id}: ${messages[0]}`).join(" ")
      : result.error.message;
  } catch {
    status.textContent = "Couldn't reach the server. Try again.";
  }
  button.disabled = false;
});
```

- **HTML** needs no JavaScript. After a successful post the browser is redirected to the form's redirect URL, or to Ikiform's hosted thank-you page.
- **React** uses the SDK: `npm install @ikiform/sdk`. Field errors from the server appear under each field.
- **JavaScript** enhances the HTML form with `fetch`, so the visitor stays on the page.
- The hidden `_gotcha` input is the honeypot. Leave it empty and hidden.

> If you set the form's spam check to **Ikiform**, the HTML form keeps working with no change and the server action skips the check because it sends a secret key. The React and JavaScript versions must get a token before each submit: call `challenge(FORM_ID)` from `@ikiform/sdk` and pass it to `submit` as `turnstileToken`, or add it to the `fetch` body as `cf-turnstile-response`. Without it the post is refused with `403 challenge_required`. See [the Next.js guide](https://ikiform.com/docs/guides/nextjs.md#client-human-check) and [Spam protection](https://ikiform.com/docs/spam.md#human-check).

## The fields

Paste this definition on the form's **Fields** tab in the dashboard. Ikiform then checks every post against it on the server, refuses bad data with `422 validation_failed`, refuses any field that isn't listed, and stores typed values.

| Field id | Type | Required | Accepts |
| --- | --- | --- | --- |
| `name` | `text` | Yes | Up to 120 characters |
| `email` | `email` | Yes | One valid address |
| `attending` | `radio` | Yes | One of: `yes`, `no` |
| `guests` | `number` | No | 0 to 3, whole numbers |
| `dietary` | `text` | No | Up to 200 characters |

`Field definition`:

```json
{
  "version": 1,
  "title": "RSVP",
  "fields": [
    {
      "id": "name",
      "type": "text",
      "label": "Name",
      "required": true,
      "validation": {
        "max_length": 120
      }
    },
    {
      "id": "email",
      "type": "email",
      "label": "Email",
      "required": true
    },
    {
      "id": "attending",
      "type": "radio",
      "label": "Can you make it?",
      "required": true,
      "options": [
        {
          "value": "yes",
          "label": "Yes"
        },
        {
          "value": "no",
          "label": "No"
        }
      ]
    },
    {
      "id": "guests",
      "type": "number",
      "label": "Extra guests",
      "help_text": "Not counting you. Up to 3.",
      "validation": {
        "min": 0,
        "max": 3,
        "integer": true
      }
    },
    {
      "id": "dietary",
      "type": "text",
      "label": "Dietary needs",
      "validation": {
        "max_length": 200
      }
    }
  ],
  "settings": {
    "submit_label": "Send RSVP",
    "success_message": "Thanks. Your RSVP is in."
  }
}
```

## Next.js server action

To keep the form working without JavaScript in a Next.js app, post from a server action with a secret key. Create the key under **API keys** with the `submissions:write` scope and keep it out of the browser.

`.env.local`:

```bash
IKIFORM_SECRET_KEY=ik_sk_your_secret_key
```

`app/event-rsvp/actions.ts`:

```ts
"use server";

export interface RsvpFormState {
  ok: boolean;
  message: string;
  fields?: Record<string, string[]>;
  values?: Record<string, string[]>;
}

export async function sendRsvpForm(_previous: RsvpFormState, formData: FormData): Promise<RsvpFormState> {
  const body = new URLSearchParams();
  for (const [name, value] of formData) {
    if (typeof value === "string" && !name.startsWith("$ACTION")) body.append(name, value);
  }
  const response = await fetch("https://ikiform.com/f/frm_xxxxxxxxxxxxxxxx", {
    method: "POST",
    headers: {
      Accept: "application/json",
      Authorization: `Bearer ${process.env.IKIFORM_SECRET_KEY}`,
    },
    body,
  });
  const result = await response.json();
  if (result.ok) return { ok: true, message: "Thanks. Your RSVP is in." };
  const values = Object.fromEntries([...new Set(body.keys())].map((name) => [name, body.getAll(name)]));
  return { ok: false, message: result.error.message, fields: result.error.fields, values };
}
```

`app/event-rsvp/form.tsx`:

```tsx
"use client";

import { useActionState } from "react";
import { type RsvpFormState, sendRsvpForm } from "./actions";

const initial: RsvpFormState = { ok: false, message: "" };

export function RsvpForm() {
  const [state, action, pending] = useActionState(sendRsvpForm, initial);

  if (state.ok) return <p>{state.message}</p>;

  return (
    <form action={action}>
      <label htmlFor="name">Name</label>
      <input id="name" name="name" type="text" maxLength={120} required defaultValue={state.values?.name?.[0]} />
      {state.fields?.name ? <p>{state.fields.name[0]}</p> : null}
      <label htmlFor="email">Email</label>
      <input id="email" name="email" type="email" autoComplete="email" required defaultValue={state.values?.email?.[0]} />
      {state.fields?.email ? <p>{state.fields.email[0]}</p> : null}
      <fieldset>
        <legend>Can you make it?</legend>
        <label><input type="radio" name="attending" value="yes" required defaultChecked={state.values?.attending?.includes("yes")} /> Yes</label>
        <label><input type="radio" name="attending" value="no" required defaultChecked={state.values?.attending?.includes("no")} /> No</label>
      </fieldset>
      {state.fields?.attending ? <p>{state.fields.attending[0]}</p> : null}
      <label htmlFor="guests">Extra guests</label>
      <input id="guests" name="guests" type="number" min={0} max={3} step={1} aria-describedby="guests-help" defaultValue={state.values?.guests?.[0]} />
      <p id="guests-help">Not counting you. Up to 3.</p>
      {state.fields?.guests ? <p>{state.fields.guests[0]}</p> : null}
      <label htmlFor="dietary">Dietary needs</label>
      <input id="dietary" name="dietary" type="text" maxLength={200} defaultValue={state.values?.dietary?.[0]} />
      {state.fields?.dietary ? <p>{state.fields.dietary[0]}</p> : null}
      <input name="_gotcha" type="text" tabIndex={-1} autoComplete="off" hidden />
      {state.message && !state.fields ? <p role="alert">{state.message}</p> : null}
      <button disabled={pending}>{pending ? "Sending" : "Send RSVP"}</button>
    </form>
  );
}
```

> A server action posts from your server's IP, so the per-visitor rate limit counts every visitor together. Use the Relaxed or Custom [rate limit](https://ikiform.com/docs/rate-limits.md) for forms you send through a server.

## Set it up

1. Create a form in the [dashboard](https://ikiform.com/dashboard) and copy its id.
2. Paste the field definition on the **Fields** tab.
3. Add your site under **Allowed domains**, plus `localhost` while you develop.
4. Set the rate limit to **Custom, with a window of up to a day** if visitors post from the browser. A Custom limit with an hour or day window suits one RSVP per person, but people on the same office or venue Wi-Fi share an IP, so leave room for that. With the server action, every post comes from your server's IP, so size the Custom limit for your whole site.
5. Paste the code, send a test and check it arrives in the dashboard.

## Guest limits enforced on the server

`guests` is a `number` with `min: 0`, `max: 3` and `integer: true`. The browser's `min` and `max` help people, but anyone can bypass them; Ikiform checks the same rules on the server and refuses `5` with `Must be at most 3` and `1.5` with `Enter a whole number`. The stored value is a real number, so you can add up headcounts without parsing text.

## Closing RSVPs

Ikiform has no setting that stops a form after a set number of responses. Watch the count in the dashboard, or read `submission_count` from the [REST API](https://ikiform.com/docs/api.md), and pause the form when you are full. A paused form refuses new posts with `403 form_paused` and the message `This form is not accepting submissions right now.`

## Changed answers

Someone who RSVPs yes and later no creates two submissions. Treat the latest submission per `email` as the answer, which is easy in a spreadsheet sorted by date or in a webhook handler that upserts by email.

- Rate limits are counted per IP. The Custom preset takes a window of 10 seconds, a minute, 10 minutes, an hour or a day, and up to 1,000 posts per window but no more than 600 a minute, so at most 100 every 10 seconds. A day window with a limit of 1 would block a second RSVP from the same Wi-Fi.
- An identical repeat within 24 hours adds to the spam score but isn't spam on its own.

## Where to send it

Every submission can go to a signed [webhook](https://ikiform.com/docs/webhooks.md). These guides cover the tools that suit this form, with the relay code:

- [Send form submissions to Google Sheets](https://ikiform.com/blog/form-submissions-to-google-sheets.md)
- [Send form submissions to Airtable](https://ikiform.com/blog/form-submissions-to-airtable.md)
- [Send form submissions to a Notion database](https://ikiform.com/blog/form-submissions-to-notion.md)
- [Send form submissions to Slack](https://ikiform.com/blog/form-submissions-to-slack.md)

## Questions

### Can I stop accepting RSVPs when the event is full?

Not automatically. Check the submission count in the dashboard or through the REST API and pause the form. Paused forms refuse new posts with 403 form_paused.

### How do I stop people bringing too many guests?

Use a number field with a max. Ikiform enforces min, max and integer on the server, so a crafted post can't get past the limit.

### Can I limit each person to one RSVP?

Only per IP, with a Custom rate limit. Ikiform doesn't block a second submission from the same email, so treat the latest submission per email as the answer.
