Last week I built Zalanie24, a small Next.js site for people in Poland whose flat has just been flooded. The visitor describes the damage in a short form, and the enquiry goes to one drying company in their area. No marketplace, no "get 5 quotes". One lead, one firm, a fixed fee.

The site itself is boring. The plumbing behind the form had five traps that each cost me an evening. Here they are, with the code that fixed them.

1. Routing by postcode prefix: 21- is not the same as 21-0

Polish postcodes look like 20-123. The first two digits give you a region, but regions are big. Lublin city is 20-, while 21- covers half of the Lublin voivodeship, including towns 100 km away that the partner does not serve.

So the partner config holds prefixes of different lengths:

export const PARTNERS: Partner[] = [ { id: "osuszaniepro24", price: 120, cities: ["lublin", "rzeszow"], // fallback when there is no postcode // Lublin + county (Świdnik 21-040, Łęczna 21-010 …), Rzeszów + county, Łańcut 37-100 postcodes: ["20-", "21-0", "35-", "36-0", "37-10"], }, ]; export function findPartner(postcode: string, citySlug: string) { if (/^\d{2}-\d{3}$/.test(postcode)) return PARTNERS.find((p) => p.postcodes.some((pre) => postcode.startsWith(pre))); return PARTNERS.find((p) => p.cities.includes(citySlug)); } 

Two lessons. Store prefixes as strings with the dash, so "21-0" can never match 21-5xx by accident. And always keep a fallback: the "call me back" box originally had no postcode field, so those leads matched nobody. Now the fallback is the city page the visitor was on.

2. The SMS that was 3 messages long

Drying firms need to call back fast, so each lead also goes out as an SMS. My first message was Polish, with Polish letters:

Zalanie24: nowy klient. Pęknięta rura, dzisiaj. Prosimy oddzwonić…

One ę switches the whole SMS from the GSM-7 alphabet (160 characters) to UCS-2 (70 characters). My 140-character message became 2 or 3 billed parts. The fix is dull and effective:

function ascii(t: string): string { const map: Record<string, string> = { ą: "a", ć: "c", ę: "e", ł: "l", ń: "n", ó: "o", ś: "s", ź: "z", ż: "z", Ą: "A", Ć: "C", Ę: "E", Ł: "L", Ń: "N", Ó: "O", Ś: "S", Ź: "Z", Ż: "Z", "–": "-" }; return t.replace(/[ąćęłńóśźżĄĆĘŁŃÓŚŹŻ–]/g, (c) => map[c]).slice(0, 160); } 

Note the en dash. It is not in GSM-7 either, and my form labels were full of them.

3. "error": null is not an error

The SMS provider (SMSAPI.pl) answers a successful send with JSON like this:

{ "count": 1, "list": [{ "id": "…", "points": 0.16, "status": "QUEUE", "error": null }] } 

My first success check was !/"error"/.test(body). Every successful SMS triggered a "SMS failed" alert on my phone, right after the SMS itself had arrived. Parse it:

let ok = false; try { const j = JSON.parse(out); ok = res.ok && !j.error && Number(j.count) > 0; } catch {} 

Also: return null (not false) when SMS is not configured, so "not set up" and "failed" are different states and only the second one alerts.

4. Counting free leads without a database

The deal with each firm is "first 3 leads free, then a fixed fee, billed monthly". I did not want a database for a site that gets a handful of leads. Each lead is written as one JSON file to a private Vercel Blob store:

leads/<partnerId>/<YYYY-MM>/<ISO timestamp>.json 

The lead number for a partner is just the count of files under leads/<partnerId>/. A cron on the 1st of the month lists last month's prefix and sends me a billing summary. Test leads (name starts with TEST) are routed to me instead of the firm and never written, so testing does not eat a partner's free leads.

5. Ten "visitors from Warsaw" in one minute

I get a silent Telegram ping per real visitor (Poland only, bots filtered by user agent). The morning I emailed ten companies a link to my other site, I got a burst of "new visitor, Warsaw, desktop, direct" within the same minute.

Those were not people. Mail providers open every link in an incoming email to scan it, and many of their servers sit in Warsaw. A user-agent filter does not catch them, because they use normal browser user agents. What worked was dedupe plus a burst limit:

const seen = new Map<string, number>(); let burst: number[] = []; function shouldAlert(ip: string): boolean { const now = Date.now(); for (const [k, t] of seen) if (now - t > 30 * 60_000) seen.delete(k); if (ip && seen.has(ip)) return false; // same visitor: once per 30 min if (ip) seen.set(ip, now); burst = burst.filter((t) => now - t < 2 * 60_000); if (burst.length >= 2) return false; // max 2 alerts per 2 minutes burst.push(now); return true; } 

It is per warm instance, so it is not exact. For alerts that is fine: I want fewer false pings, not perfect analytics.

Bonus: tell Bing right away

Google finds new pages from the sitemap in its own time. Bing (which also feeds a few AI search tools) supports IndexNow: put a random key in a text file at the site root, then POST your URLs.

curl -X POST https://api.indexnow.org/indexnow \ -H "Content-Type: application/json" \ -d '{"host":"zalanie24.pl","key":"<key>","keyLocation":"https://zalanie24.pl/<key>.txt","urlList":["https://zalanie24.pl/"]}' 

A 202 means accepted.

None of this is clever. It is the kind of detail that decides whether a small lead site makes money or quietly loses leads. If you build something similar, start with the SMS alphabet and the "error": null check. They are the cheapest bugs to avoid.