If you have ever built a lead form for a local service business, you know the temptation. Everyone has one more field they want. Square footage, number of pets, preferred contact method, how did you hear about us, and a free text box for "anything else."
Working on the ops side of a cleaning company in Toronto, I end up thinking about this a lot. The quote form is the top of the funnel, and every field is a small tax on the person filling it out. The real job is figuring out which inputs actually move the price and which ones just make you feel thorough.
Model the price first, then build the form
Before touching any HTML, write down the pricing function. For residential cleaning it looks roughly like this:
function estimate({ serviceType, bedrooms, bathrooms, condition, addOns }) { const base = BASE_RATES[serviceType]; const rooms = bedrooms * ROOM_RATE + bathrooms * BATH_RATE; const multiplier = CONDITION_MULTIPLIER[condition] ?? 1; return (base + rooms) * multiplier + sumAddOns(addOns); } Every argument in that signature earns a field. Anything that is not an argument does not belong in step one. Square footage sounds important, but bathrooms and kitchen condition drive labour time far more than raw area, so it can safely become optional.
Different services need different inputs
A single generic form breaks down fast once you offer more than one service. An office clean depends on headcount and visit frequency. A move-out clean depends on whether the unit is empty and whether appliances are included. Short-term rentals are their own thing entirely, because the variable that matters most is the turnover window between checkout and the next check-in, not the size of the unit.
That last case shows why the schema should follow the service. For a condo host looking into Airbnb cleaning in Liberty Village, the useful questions are checkout time, next check-in time, linen handling and how the cleaner gets in. Bedroom count barely matters for a one-bed unit in a tower where elevator waits eat into the window anyway.
So the form branches. Step one asks for the service type, and step two renders a field set from a config object keyed by that type. Adding a new service means adding config, not new components.
Validate for humans, not for the database
Phone numbers are where most local business forms get hostile. People type them with spaces, dashes, brackets, a leading 1, or no area code at all. Normalize on the server, accept anything with ten digits after stripping, and stop showing red error text for formatting choices.
Postal codes are similar. A Canadian postal code makes a tidy regex, but the real question is whether the address is inside your service area. Check the first three characters against the list of areas you cover and show a friendly message if it falls outside.
The follow-up is part of the form
The submit button is not the end of the flow. A quote request that sits in an inbox for a few hours is often a lost lead, because the person filled out two other forms in the meantime. An automated SMS that confirms what happens next, plus a human follow-up queued in whatever dispatch tool you use, tends to matter more than any field tweak.
Treat it like an async job: enqueue on submit, retry if the SMS fails, log delivery status, and alert someone if a lead has not been touched within a set window.
A short checklist
- Write the pricing function before the form.
- One required field per argument, everything else optional or deferred.
- Branch the schema by service type with config, not code.
- Normalize input on the server instead of policing it in the UI.
- Count the follow-up as part of the form's latency budget.
None of this is novel engineering. It is the same discipline you would apply to API design, pointed at a form that a tired person fills out on their phone at 10 p.m.

