# How to add a waitlist to a Framer site

> Two ways to collect waitlist signups on Framer: Framer's form with a webhook to Ikiform, or a code component that keeps per-visitor limits and spam checks.

Published 2026-10-25 · https://ikiform.com/guides/waitlist/framer

Framer forms can't post to an outside URL, but they can send each submission to a webhook, so the no-code route is Framer's form with your Ikiform endpoint as the webhook. For a public launch, use a small code component instead: the browser posts directly, so Ikiform's per-visitor rate limit, allowed domains and honeypot all keep working.

Framer is where a lot of pre-launch pages are built, because the page itself takes an afternoon. The waitlist form is where it gets less obvious. Framer's form block can't change its `action`, so you choose between two routes that behave differently once real traffic arrives.

## What's different on Framer

- **No custom action.** A Framer form sends to Framer's destinations: its own storage, email, or a **Webhook**. A webhook is the only way to reach an outside backend without code.
- **Webhooks come from Framer's servers, not the visitor.** Ikiform sees Framer's IP addresses and no `Origin` header. That turns off three protections: allowed domains, the per-visitor rate limit and the human check.
- **Retries.** Framer treats anything but a `2xx` as a failure and retries up to five times. It doesn't follow redirects. Ikiform answers a JSON post with `201`, so a stored signup is a success the first time.
- **Code components are real React.** They run in the visitor's browser on the published site, so a form inside one posts like any other web page.

Diagram: Two ways a Framer waitlist signup reaches Ikiform

1. With the webhook route, the visitor submits Framer's form and Framer's servers forward the fields to Ikiform as JSON.
2. Ikiform sees Framer's servers as the sender, so it can't tell visitors apart or check which site the form is on.
3. With the code component, the visitor's browser posts straight to Ikiform.
4. Ikiform sees each visitor's IP and your domain, so every protection applies.

Both routes store the same signup. They differ in who Ikiform thinks sent it.

## Pick a route

|   | Framer form with a webhook | Code component |
| --- | --- | --- |
| Setup | Two fields in the right sidebar | One code file, pasted once |
| Styling | Framer's form styles and states | Inline styles you edit, or your own classes |
| Per-visitor rate limit | No. All posts share Framer's IPs | Yes, 5 a minute each |
| Allowed domains | Leave empty | Set to your Framer and custom domains |
| Honeypot | No | Yes |
| Human check | Off | Off, because it posts with `fetch` |
| Source tracking | None | `?ref=` from the URL, or a default per instance |

The webhook route is fine for a page you share with a few hundred people. If you're about to post the page somewhere big, use the code component: a burst of bot signups through Framer's servers can only be stopped by spam scoring.

## Route 1: Framer's form with a webhook

1. Add a form to the page with one **Email** input. In the input's settings, set its name to `email`. Framer sends each input's name as the JSON key, and Ikiform uses `email` for reply-to and the disposable-domain check.
2. Select the form. Next to **Send To**, choose **Add…** › **Webhook** and paste your endpoint, `https://ikiform.com/f/frm_xxxxxxxxxxxxxxxx`. The signing secret is optional; Ikiform doesn't check Framer's signature.
3. In Ikiform, leave **Allowed domains** empty and the spam check off. With either one on, every Framer post is flagged or refused, because it carries no origin and can't run a browser check.
4. Set the [rate limit](https://ikiform.com/docs/rate-limits.md) to Relaxed, or Custom on launch day. Standard counts all of Framer's posts as one visitor, and the whole form is capped at 300 a minute.
5. Style the form's success state in Framer. It shows when Ikiform answers `201`.

> A post Ikiform refuses, such as a missing email with a field definition in place, fails the same way on all five of Framer's retries. Check the delivery in Framer if signups stop arriving.

## Route 2: a code component

In the **Assets** panel, open **Code**, create a code file named `WaitlistSignup`, and paste this. Drag it onto the page and set **Form ID** in the properties panel.

`WaitlistSignup.tsx`:

```tsx
import { addPropertyControls, ControlType } from "framer";
import { type CSSProperties, type FormEvent, useRef, useState } from "react";

const box: CSSProperties = {
  font: "inherit",
  padding: "12px 14px",
  borderRadius: 10,
  border: "1px solid rgba(0, 0, 0, 0.15)",
};

interface Props {
  formId: string;
  buttonLabel: string;
  joinedText: string;
  source: string;
}

export default function WaitlistSignup({ formId, buttonLabel, joinedText, source }: Props) {
  const [status, setStatus] = useState<"idle" | "sending" | "joined">("idle");
  const [error, setError] = useState("");
  const key = useRef("");

  async function onSubmit(event: FormEvent<HTMLFormElement>) {
    event.preventDefault();
    const body = new FormData(event.currentTarget);
    const ref = new URLSearchParams(window.location.search).get("ref");
    body.set("ref", (ref || source).slice(0, 40));
    key.current ||= crypto.randomUUID();
    setStatus("sending");
    setError("");
    try {
      const response = await fetch(`https://ikiform.com/f/${formId}`, {
        method: "POST",
        headers: { Accept: "application/json", "Idempotency-Key": key.current },
        body,
      });
      const result = await response.json();
      if (result.ok) return setStatus("joined");
      setError(result.error.fields?.email?.[0] ?? result.error.message);
    } catch {
      setError("Couldn't reach the server. Try again.");
    }
    setStatus("idle");
  }

  if (status === "joined") return <p role="status" style={{ margin: 0 }}>{joinedText}</p>;

  return (
    <form onSubmit={onSubmit} style={{ display: "flex", flexWrap: "wrap", gap: 8, width: "100%" }}>
      <input
        name="email"
        type="email"
        placeholder="you@company.com"
        aria-label="Email"
        autoComplete="email"
        required
        style={{ ...box, flex: "1 1 220px" }}
      />
      <input name="_gotcha" type="text" tabIndex={-1} autoComplete="off" hidden />
      <button type="submit" disabled={status === "sending"} style={{ ...box, cursor: "pointer" }}>
        {status === "sending" ? "Joining" : buttonLabel}
      </button>
      {error ? <p role="alert" style={{ flexBasis: "100%", margin: 0 }}>{error}</p> : null}
    </form>
  );
}

addPropertyControls(WaitlistSignup, {
  formId: { type: ControlType.String, title: "Form ID", defaultValue: "frm_xxxxxxxxxxxxxxxx" },
  buttonLabel: { type: ControlType.String, title: "Button", defaultValue: "Join the waitlist" },
  joinedText: { type: ControlType.String, title: "Joined text", defaultValue: "You're on the list." },
  source: { type: ControlType.String, title: "Source", defaultValue: "framer" },
});
```

- The properties panel exposes the button label, the joined text and a default **Source**. Drop one instance in the hero with `hero` and another in the footer with `footer`, and you'll see which converts.
- A `?ref=` in the page URL wins over the default, so campaign links tag themselves.
- The idempotency key is made on the first submit and reused on retries from the same visit, so a double tap on a phone stores one signup.
- `window` is only read inside the submit handler. Framer renders components on the server first, and touching `window` during render would break that.

### Ikiform settings for the component

Add these on the form's **Fields** tab, so the email is required and `ref` is allowed:

`Fields`:

```json
{
  "version": 1,
  "title": "Waitlist",
  "fields": [
    { "id": "email", "type": "email", "label": "Email", "required": true },
    { "id": "ref", "type": "text", "label": "Source", "validation": { "max_length": 40 } }
  ]
}
```

- Under **Allowed domains**, add `your-project.framer.app` and your custom domain. Never add `framer.app` on its own; every Framer site would match.
- Keep the spam check off. The component posts with `fetch` and doesn't fetch a token, so a form with the check on answers `403 challenge_required`.
- Keep Standard rate limits. Each visitor is counted on their own now.

## Check it works

1. Publish the site. Webhooks and code components only post from the published site, not the editor preview.
2. Submit the form on your `framer.app` domain and again on your custom domain.
3. Open **Submissions** in the dashboard. The new submission is there with every field you sent.
4. For the component, open the page with `?ref=test` and check the signup shows `ref` as `test`.
5. Check the inbox of each notification recipient. Each gets an email with the fields, and **Reply** goes to the submitter when the form has an `email` field.
6. Nothing arrived? Switch the dashboard's status filter to **Spam**. Spam gets the same success answer as a real submission.

## Gotchas

| Problem | What to do |
| --- | --- |
| Every webhook signup is under spam with `origin_mismatch` | **Allowed domains** is set, and Framer's server posts have no origin. Clear it for this form, or switch to the code component. |
| Framer reports failed webhook deliveries | Ikiform refused the post: `403 challenge_required` with the spam check on, or `422` because an input name isn't `email`. Fix the cause; retries fail the same way. |
| `429 rate_limited` | One visitor sent more than the form's rate limit allows. Standard allows 5 a minute per visitor. Wait for `Retry-After`, or choose Relaxed or Custom for forms that post from one shared IP. |
| The component shows `Unknown field "ref"` | The field definition doesn't list `ref`. Add it as in the definition above. |
| `403 origin_not_allowed`, or every post lands in spam | The page's domain isn't in the form's **Allowed domains**. Add it, plus `localhost` for local tests. Subdomains of a listed domain are allowed. |

The full Framer setup, including the contact-form version, is in the [Framer docs guide](https://ikiform.com/docs/guides/framer.md). Exporting the list and sending the launch email are in the [main guide](https://ikiform.com/guides/waitlist.md#launch-day).

## Questions

### Can a Framer form post to an external URL?

Not as a form action. Framer forms send to Framer's own destinations, and one of them is a webhook to any https URL. Use that for Ikiform, or render your own form with a code component.

### Why are my Framer waitlist signups marked as spam?

With the webhook route, Framer posts from its servers without an Origin header, so a form with Allowed domains set marks every post as origin_mismatch. Leave Allowed domains empty for webhook forms.

### Do Framer webhook signups count against Framer's form limits?

Framer says form submissions are unlimited, so webhook signups don't run into a Framer cap. The code component route doesn't use Framer's forms at all.

### Is Ikiform free?

No, there is no free plan. Testing is free: you can send 50 test submissions a month from localhost before you pay. To go live, Pro starts at $6/mo for 500 submissions, or you can pay once for 10,000 submissions for $49 or 50,000 submissions for $149, with credits that never expire.

## More guides

- [Waitlist for a new SaaS](https://ikiform.com/guides/waitlist.md): Build a pre-launch waitlist on your own landing page: the form, spam protection, duplicates, confirmation emails and a clean export on launch day.
- [Waitlist on Webflow](https://ikiform.com/guides/waitlist/webflow.md): Point a Webflow Form Block at Ikiform, tag signups by source with a hidden field, and keep Webflow's success state with a short script for every form.
- [Waitlist on Carrd](https://ikiform.com/guides/waitlist/carrd.md): Collect waitlist signups on a Carrd page: a Send to URL form on Pro Plus with UTM tracking, or an embedded HTML form with a honeypot on Pro Standard.
- [Plain HTML waitlist](https://ikiform.com/guides/waitlist/html.md): A complete no-build waitlist page in plain HTML that works with JavaScript off, shows an inline thank-you when it loads, and falls back when it must.
- [Product Hunt launch list](https://ikiform.com/guides/waitlist/product-hunt.md): Collect a launch list weeks before Product Hunt, tag each signup by channel, and send the launch-day email in two waves timed to the Pacific Time reset.
- [Beta signup with screening](https://ikiform.com/guides/waitlist/beta-signup.md): A beta application form with screening questions the server enforces, and a script that scores applicants and picks each cohort from the list.
