Every local shop I look at has the same hole. Five stars on Google Maps, a price list taped to the counter, and a "website" button on the listing that goes to a Facebook page last updated in 2022. About 17% to 43% of small businesses still have no site, depending on which survey you read, and plenty of the ones that do have a page that cannot answer "how much" and "are you open" without a tap.
I built the smallest thing that answers those two questions, and put the bottom tier at $0. This post is the engineering: what it is made of, what it costs to run, and the two design decisions I am still not sure about.
The business reason first, because it shaped the code
Six services a shop could use as its one bio link, priced over 36 months at each vendor's own annual rate, read in November 2026:
- Linktree: Starter $7/mo = $252, Pro $14 = $504, Premium $30 = $1,080
- Beacons: Free $0, Creator $100/yr = $300, Plus $300/yr = $900, Max $1,000/yr = $3,000
- Wix: Light $17/mo = $612, Core $29 = $1,044, Business $39 = $1,404
- Squarespace: Basic $19/mo = $684, Core $29 = $1,044, Plus $49 = $1,764
- MapToSite: Card $0, Board $6.99/mo = $251.64, Sign $13.99/mo = $503.64
The entry tiers land inside a $430 band and the useful tiers inside a $500 one. Price does not separate these products, so I could not compete on price. The only honest differentiator left was what the page contains — which meant the page had to be cheap enough to give away and good enough to be worth the link.
The flow, end to end
- The hero input takes a Google Maps link (or a place name).
POST /api/placeresolves it to{name, industry, lat, lng, maps_url}. Only the URL and its redirect are read: the place name comes out of the address (/maps/place/<name>/@lat,lng), the industry from a word list (barber, cafe, food, beauty, hair, fitness...), and an unreadable link just falls back to typing the name. Google's page is not scraped and nothing is stored. -
/createis a two-step form: business type, then name, phone, hours and three prices. Picking an industry tab advances the step by itself, so the only decision on screen is the one being asked. - Publish mints a slug, returns
{ok, slug}, and the page renders at/s/{slug}— about 64 KB of HTML with no framework and no client bundle worth measuring. - The edit key is the only credential. Paste it on
/dashboardand the page opens for edits. No email, no password, no session, nothing to breach but a random string the owner was shown once.
No sign-up is an architecture choice, not a growth trick: the record is created by the request that needs it, and the key is the whole identity.
The beacon: telling a reader from a link preview
Attribution is one file, /assets/beacon.js — 2,156 bytes, 1.1 KB gzipped, no cookie, no id. It posts a short JSON body to /api/beacon with two-character keys (p path, r referrer, s/m/c/t UTM fields, e event, x viewport) so the payload stays under 2 KB, which is also the worker's own limit.
The worker writes one row into D1:
traffic_events(id, created_at, path, referrer, utm_source, utm_medium, utm_campaign, utm_content, ip_hash, event, meta, is_internal, internal_reason) Three details in there are the whole analytics stack:
- Same-site untagged moves are dropped. A
viewwith no UTM whose referrer is our own origin is a page the visitor already counted, so "direct" does not inflate as people click around. -
metacarries the product and the viewport (mt-m390= MapToSite, 390px wide). One D1 database serves two products without a schema fork; themt-prefix is the join condition. - Rows are marked rather than deleted.
is_internalplusinternal_reason(ops-ip,ops-cookie,burst, machine traffic) keeps the owner's own checks and bot traffic in the table but out of the numbers a human reads.
The burst rule came from data: four seconds, more than one distinct ip_hash on the same path. When a page is pasted into Facebook, Slack or iMessage, the preview renderer fetches it within seconds from several addresses — I measured 49 "visits" landing in three seconds from 2–6 IPs after one post. A human reading a link does not do that. The channel rank query becomes:
SELECT COALESCE(NULLIF(utm_source, ''), 'direct') AS src, COUNT(*) AS n FROM traffic_events WHERE meta LIKE 'mt-%' AND event = 'view' AND is_internal = 0 AND created_at >= ?1 GROUP BY src ORDER BY n DESC; Two bugs worth writing down
Open-now in the wrong time zone. The badge was computed from the shop's typed hours against the server's clock, which is Korean. A café in New York read "Open now" at midnight. The fix stores an IANA zone at publish time (tz=America/New_York) and evaluates the hours in that zone; pages published before the fix keep their old behaviour until edited. tests/open_state.mjs is at 97 passing — the case that breaks naive string comparison is a shop that closes at 2am.
Selling features the free plan already had. The pricing page listed "visit stats", "CSV export" and "inbox" as paid; the free plan returned all three. The fix was not copy, it was a table generated from the feature gate itself, so the page cannot drift from the code again.
What it costs to run
Pages and Workers on the free tier, D1 on the free tier, Polar as merchant of record so no card data touches the site. At the current volume — single-digit daily visitors, no paying customers — the marginal cost of a published page rounds to zero. If that stops being true, the paid tiers already answer it: $6.99 removes the footer credit and adds a photo gallery and a notice line; $13.99 adds the owner's domain and a one-time build.
The two questions I cannot answer from the code
Does a $0 tier pay for itself? The free page carries a "Free page · MapToSite" footer line. That is marketing if the people who see it are shop owners and noise if they are not. The beacon cannot tell me that yet, because it counts visits, not who is visiting.
Is an edit key better than an account? Less friction, less to breach, and a random string a barber can lose — and I cannot recover it for him. Accounts exist because they make recovery someone else's job; giving that up is a real cost, not a feature.
The comparison pages name the vendor URL and the date each price was read: the Linktree one, and the same table for Beacons, Wix and Squarespace. The builder itself: maptosite-app.pages.dev/create.
I build MapToSite; the $0 tier in that table is mine.


