Add a lead form
Recipe — collect a visitor's contact details and submit them to HomePages as a lead.
A lead form is an island — a "use client" component — that calls
submitLead from @homepages/template-kit/browser. The kit owns the whole request:
signing, the idempotency key, retry, and source attribution. You own the markup and
what the visitor sees.
The island
Section titled “The island”sections/contact/components/LeadForm.tsx:
"use client";
import { submitLead } from "@homepages/template-kit/browser";import { useState } from "react";
type Status = "idle" | "sending" | "done" | "error";
export default function LeadForm() { const [email, setEmail] = useState(""); const [name, setName] = useState(""); const [status, setStatus] = useState<Status>("idle"); const [error, setError] = useState("");
async function submit(): Promise<void> { setStatus("sending"); const result = await submitLead({ email, name }); if (result.ok) { setStatus("done"); return; } setError(result.message); setStatus("error"); }
if (status === "done") return <p>Thank you — we'll be in touch.</p>;
return ( <form data-tr-lead-form onSubmit={(event) => { event.preventDefault(); void submit(); }} > <input name="name" value={name} placeholder="Name" onChange={(event) => setName(event.target.value)} /> <input name="email" type="email" required value={email} placeholder="Email" onChange={(event) => setEmail(event.target.value)} /> <button type="submit" disabled={status === "sending"}> Submit </button> {status === "error" && <p role="alert">{error}</p>} </form> );}
// Submitting a lead is not an editing interaction, so this island stays static// server-rendered HTML in the editor canvas. It still hydrates on published pages.export const editor = { live: false };Two attributes are load-bearing:
data-tr-lead-formon the<form>— how HomePages detects that a visitor engaged with the form.export const editor— required of every island; see Islands.
What you send
Section titled “What you send”Only email is required. name, phone, and message are named because almost
every form collects them. Anything else goes in extra:
await submitLead({ email, name, phone, message, extra: { interest: "3br", lead_intent: "potential-buyer" },});extra cannot overwrite the named fields or the source attribution. Precedence runs
extra → named contact fields → attribution, last one winning.
You never add attribution yourself. The referring site, the utm_* parameters, and
the page URL are captured automatically and reach the site owner’s lead inbox.
Handling the result
Section titled “Handling the result”submitLead never throws. It returns a result you branch on:
const result = await submitLead({ email });
if (result.ok) { // Sent. `result.simulated` is true when there was no site config — see below.} else { result.reason; // "missing-email" | "in-flight" | "too-large" // | "unauthorized" | "rejected" | "network" result.message; // a sentence you can show the visitor as-is}Show result.message rather than writing your own copy per reason — it already
describes what the visitor should do.
A network error or a server error is retried for you, twice, with backoff, reusing
one idempotency key — so a retry can never create a duplicate lead. A rejected
request (a 4xx) is not retried, because retrying it cannot help.
Previewing before you publish
Section titled “Previewing before you publish”A lead is only submittable from a published page, which is where the per-site config
lives. In the editor canvas and in template-kit dev there is no config, so
submitLead sends nothing and returns { ok: true, simulated: true }.
Your success UI is therefore exercised exactly as it will behave live, and the console tells you plainly that nothing was sent. If you want the success state to say so during authoring, branch on it:
if (result.ok && result.simulated) { // preview only — no lead was created}Reaching another endpoint
Section titled “Reaching another endpoint”submitLead is the supported path for leads. For any other signed request, the same
entry exports the primitives underneath it — readSiteConfig() and
signedIngestHeaders(config, body). See Islands.
Then run the author loop: edit, preview, check.