Add a lead form
Collect a visitor's contact details and submit them to HomePages as a lead.
Collect a visitor’s contact details and submit them to HomePages as a lead.
Prerequisites
Section titled “Prerequisites”- A section to hold the form — Install scaffolds one, with
Renderer.tsxalongsidesection.tsandfixtures.ts. - A
components/folder inside it for the island file — Organize a section’s files adds one.
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 declarations 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, andliveis written out either way.falseis the right answer here: submitting a lead is not an editing interaction, so the form has nothing to do in the canvas. 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.
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.
Complete diff
Section titled “Complete diff”The island file under Steps is the whole change — render it from
Renderer.tsx like any other component. section.ts and fixtures.ts are
untouched: a lead form collects what a visitor types, so it declares no slot and
needs no fixture value.
Verify
Section titled “Verify”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:
const result = await submitLead({ email, name });if (result.ok && result.simulated) { // preview only — no lead was created}Then run the author loop: edit, preview, check.
Rules that can fire
Section titled “Rules that can fire”require-island-editor— the form island has a default export but no literaleditordeclaration.no-client-runtime-in-server— the form’suseStatecalls or its submit handler stay inRenderer.tsxinstead of moving into the island.no-client-directive-in-contract— the"use client"directive lands inRenderer.tsxor a declaration file instead of the form’s own file.
See also
Section titled “See also”- Make a section interactive — the island shape this one follows, and the marker question an island that renders a slot has to answer.
- Islands — the props contract, the editor declaration, and the DOM contract a published page hydrates.