Waitlist for a new SaaS

Waitlist for a new SaaS6 min read

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.

Preet SutharFounder, Ikiform

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.
Two ways a Framer waitlist signup reaches IkiformFramer form with a webhookCode componentFramer formon your pageFramer serversJSON, retries up to 5timesIkiformsees Framer's IP, noorigincode componentReact form on yourpagevisitor's browserfetch with anidempotency keyIkiformsees the visitor andyour domainsubmitwebhooksubmitpostTwo ways a Framer waitlist signup reaches IkiformFramer form with a webhookCode componentFramer formon your pageFramer serversJSON, retries up to 5 timesIkiformsees Framer's IP, no origincode componentReact form on your pagevisitor's browserfetch with an idempotency keyIkiformsees the visitor and your domainsubmitwebhooksubmitpost
  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

ItemFramer form with a webhookCode component
SetupTwo fields in the right sidebarOne code file, pasted once
StylingFramer's form styles and statesInline styles you edit, or your own classes
Per-visitor rate limitNo. All posts share Framer's IPsYes, 5 a minute each
Allowed domainsLeave emptySet to your Framer and custom domains
HoneypotNoYes
Human checkOffOff, because it posts with fetch
Source trackingNone?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 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
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
{
  "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

ProblemWhat to do
Every webhook signup is under spam with origin_mismatchAllowed 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 deliveriesIkiform 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_limitedOne 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 spamThe 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. Exporting the list and sending the launch email are in the main guide.

Questions

Stuck on a step, or want a guide for your situation? Email hello@ikiform.com.