GuateLive — Local Discovery Platform for Guatemala

- Next.js 15
- TypeScript
- Tailwind CSS
- shadcn/ui
- Supabase
- PostgreSQL
- Vercel
- GitHub Actions
- Anthropic Claude API
- Google Places API
GuateLive
A live discovery platform for restaurants, cafés, and bars in Guatemala — conceived, scoped, architected, and built solo. I'm the founder and the engineer on this one: every product call, every schema decision, every line of infrastructure is mine to own, including the ones that turned out to be wrong and had to be caught.
The premise: generic listings don't get people to actually go anywhere — editorial opinion does. Structured search handles "what's open near me right now." A weekly editorial layer — each "edition" a magazine-style writeup with a food verdict, a place verdict, and an itemized bill — handles "convince me it's worth leaving the house." That product bet, not the tech stack, is the actual thing being tested here.

Why I built this (the founder bet)
Guatemala City has no shortage of restaurant-listing apps — what it lacks is a voice that tells you what to actually do tonight instead of a wall of five-star-average noise. That's a product positioning decision, not a technical one: GuateLive is built as an entertainment/discovery product first, directory second, which is why the editorial layer got built before most of the "nice to have" search polish.
A few calls I made as founder, not just as the person writing the code:
- Scoped the POC to restaurants, cafés, and bars only, for the first 6 months — instead of trying to cover nightlife, events, and culture from day one. Narrow enough that one person can sustain a real weekly editorial cadence, which is the part of the product that actually differentiates it.
- Then deliberately broke that scope early, on purpose, once traction said to. The plan had events deferred to month 4+; I pulled it forward to week 3 when it became clear an events carousel could drive attention now — a scope change I made and documented at the time, not one that quietly drifted in.
- Monetize via disclosed sponsorship, not a paywall or ads that degrade the editorial voice. Free listing by default; a paid tier (up to Q1,500/month) buys editorial placement and priority surfacing — always labeled as sponsored, including in anything an LLM might eventually read from the data. Trust in the editorial voice is the product, so the monetization design had to not undercut it.
- No Stripe, by design, from day one — it doesn't operate as a seller in Guatemala. That's a constraint I built the entire payment/monetization shape around up front, instead of discovering it mid-build and reworking the model.

Agents I designed and built
A solo founder can't hand-tag hundreds of places or hand-check a bank's website every day — so two pieces of the product are built as agents, not one-off scripts, each with a specific job and a specific boundary on what it's allowed to do wrong.
Classification agent (tagging). Places need tags across five axes — occasion, atmosphere,
price tier, features, cuisine — and today's catalog-wide coverage actually comes from deterministic
scripts (Google's own place attributes, a price-tier mapping, a hand-curated occasion/atmosphere
taxonomy). The agent designed to generalize this is a Claude Haiku pipeline (auto-tag-places.ts):
it takes a place's raw data and returns tags plus a short description, pinned to a fixed taxonomy
with strict JSON output — anything outside the allowlist gets dropped rather than trusted. It runs
in batches with per-item error isolation, so one malformed response degrades a single row instead
of the whole run, and it's deliberately barred from ever touching is_published — a bad AI output
should never silently promote or hide a listing. It's validated in test mode against a handful of
places; before it runs against the full catalog it needs one fix so its writes merge into a
place's existing tags instead of overwriting them.

Scraping agent (bank promotions). Banks publish restaurant discounts with no API and no
consistent structure, so this is built around a BankPromoSource interface — one bank live today,
designed so a second bank is one adapter file and one registry line, never a change to the
orchestrator, matching, or database logic. What makes it an agent rather than a brittle script is
that it decides when not to act: if a scrape returns zero results, it aborts before touching the
database, so a broken selector or a network blip can't be misread as "every promotion expired" and
silently deactivate real, live offers. Matching a scraped merchant name to a place is
exact-normalized-string, not fuzzy — a wrong match is worse than no match — and chains with
multiple branches get resolved per place page at read time instead of the scraper guessing a single
branch to write to. It runs unattended in production, daily, via GitHub Actions.

How I use AI as a senior engineer, not a passenger
The interesting skill here isn't "I used Claude to write code" — it's directing an AI collaborator across an entire stack (product spec → database schema → agents above → frontend → design system) while still being the one who owns the architecture, catches the AI's mistakes, and vetoes its shortcuts. A few concrete times that mattered, not hypothetical ones:
- Didn't trust "the field is 100% populated" as proof the AI tagging agent did the work. Three different scripts had written to the same tags column over time; I verified the actual values matched the agent's vocabulary before accepting the claim — and caught myself almost making the same wrong assumption a second time in the same session before writing it down as fact.
- Treated "the migration ran without error" as insufficient proof for a security-sensitive change. For row-level security specifically, verifying meant comparing the same query's result with the public key vs. the admin key — a query that returns cleanly can still be silently filtering rows in a way that looks fine and isn't.
- Found and closed a security blind spot on a table that had been created outside the versioned migration path and had therefore never gone through the same checklist as everything else — the kind of gap that's invisible unless you go looking for tables that didn't come from a migration file.
- Insisted on the fail-closed guard in the scraping agent described above — that's a judgment call about what "safe" means in this specific system, not something that falls out of "call the API and save the response."
AI is the reason a solo founder can run a content-heavy, constantly-refreshed local catalog at a pace that would normally need a small team. The architecture decisions, the skepticism about what the AI just told me, and the calls about what's safe to automate versus what needs a human in the loop are still mine.
Tech stack
| Layer | Choice |
|---|---|
| Frontend | Next.js 15/16 (App Router), React, strict TypeScript, Tailwind + shadcn/ui |
| Backend / data | Supabase (Postgres + Auth + Storage + RLS) — no ORM, direct SDK queries |
| AI | Anthropic Claude (Haiku) for structured extraction/classification |
| Data acquisition | Google Places API (cost-tiered refresh), Cheerio-based scrapers for bank promos |
| Automation | GitHub Actions (cron) |
| Deployment | Vercel |
Architecture & engineering decisions
Google Places cost model, not just "cache it." Photos are the dominant cost if fetched live
(~$0.017/photo via the Places Photo endpoint), so they're migrated once into Supabase Storage and
never requested from Google again at render time — enforced by convention: the file that used to
call the photo endpoint was deleted outright, and a live maps.googleapis.com/photo call anywhere
in the render path is treated as a regression. Everything else refreshes on a tier matched to both
data volatility and Google's own pricing: name/status/hours weekly (free, Basic Data),
rating/price-level/hours monthly (paid, Atmosphere), phone/website quarterly (paid, Contact). These
refresh scripts still run manually — there's no cron for them yet, only for the scraping agent
above.
Client-side search as a deliberate, timestamped tradeoff. /buscar loads the full catalog and
filters in the browser, which buys zero-latency filtering at current catalog size with no loading
states. It's written down as a call with an expiration condition, not a permanent one: past roughly
1,000–2,000 places, or the first time mobile load time becomes noticeable, the plan is server-side
Postgres full-text search before reaching for a dedicated search engine. Same logic on the database
layer: no ORM, direct Supabase SDK queries, revisited only if the schema needs complex many-to-many
relations or generated types.
ISR + Supabase's client split. Pages using generateStaticParams need the plain
@supabase/supabase-js client instead of the cookie-based server client, since cookies() doesn't
exist at build time — a gotcha that became a standing pattern across every ISR route
(revalidate = 3600) after it broke a page once.
Editorial voice as a schema problem, not just copy. Editions store their "verdict" as
structured ratings/text fields, and the itemized bill is a jsonb array of label/amount rows
rather than a hand-formatted string parsed with .split() — the fragile-string version broke the
moment the format drifted. Design identity (near-black dominant, a red accent that's never a large
background fill, serif display type for editorial headlines vs. sans for UI) is documented once and
applied everywhere, not decided per page.
Bubble search kept its 3-step flow on purpose. A full visual redesign of the home page considered collapsing the "what → where → when" bubble flow into a single-tap search with instant results. The call was to keep the 3-step flow and only reskin it — validating existing UX against a new visual direction instead of letting a redesign silently change product behavior.
AI roadmap
- Shipped: the classification and scraping agents described above; Schema.org/JSON-LD across place, edition, and event pages, validated in production.
- Next: generalize the classification agent from a small validated test set to the full catalog — fixing the tag-merge bug first — and scope additional narrowly-permissioned agents (event drafting from third-party sources, a read-only support assistant), each defined first by what it's not allowed to touch before what it can do.
- Further out: an MCP server exposing the catalog to LLM clients directly, gated behind 500+ well-tagged places as proof the data is worth serving.
Key learnings
- Constrained output is what makes an LLM call production-grade. A fixed taxonomy plus strict JSON plus an allowlist on the way back turns "the model might say anything" into a normal, boring failure mode instead of an unpredictable one.
- Every architectural shortcut ships with its own expiration condition. Client-side search, no ORM, manual (not cron'd) Google Places refresh — none of these are permanent calls; each has a written number or symptom attached that says when to revisit it.
- A soft-404 (a "not found" page that still returns 200) is worse than no page at all for anything meant to be indexed — worth catching explicitly rather than treating a friendly error message as good enough.