VieCenters lists language centers in Ho Chi Minh City with a source and a date on each fact, and the database itself refuses to publish a center that cannot prove the basics.
Role
Full-stack web app
Platforms
Web
Stack
Next.js, Supabase, TanStack Query, PostHog
Try it
The problem
Trust is the product
A directory of schools is easy to get wrong in a way that hurts people. A wrong phone number attributed to a real business sends a parent to the wrong place. One verified badge does not help, because nobody can ask what was checked, against what, or when.
The second problem is incentives. Directories usually earn money from the businesses they list, so position drifts toward whoever pays. Keeping the listing neutral means removing those paths from the system, not just promising not to use them.
Architecture
Rules in Postgres, pages from facts
The trust model grew in steps: a single verified flag, then a source URL and date, then one row per fact. Paid placement and lead features were removed along the way.
Next.js renders the public pages from cached Supabase reads with the publishable key, so row-level security decides what anyone can see. The rules that matter live in Postgres triggers and constraints, and the admin screen is a thin client over them. When the database refuses to publish, the screen reads the unmet criteria out of the error and shows them.
Verification
Admin, TanStack Query
Inserts sourced, dated facts
Gate
Postgres triggers
Refuses publishing, auto-unpublishes, writes the change log
Public site
Next.js
Serves published centers, expired by tag after admin writes
Analytics
PostHog
Drops admin events, strips typed search text
Every admin write also expires the public cache, so a removed center does not keep showing as verified.
Key decisions
Three decisions behind the trust
01
A fact is a row
A column can hold a value but cannot say where it came from, when it was checked or what it replaced. A blank also cannot separate a center that hides its license from one nobody has checked yet.
Each fact is a row with a value, a source type, a URL or written note and a required verification date. Edits are inserts: no role has an update policy, and a trigger retires the previous row for that slot. A partial unique index keeps exactly one current row per center, field and branch.
A check constraint rejects any fact with neither URL nor note.
No row means unverified, while not public is a row with proof.
Stale is derived after 6 months and never stored.
02
The database decides what publishes
A rule enforced only in the admin UI can be skipped by a script or broken by a later change. A center can also lose a qualification after it has gone live.
A trigger on centers raises a check violation unless four criteria hold: a license, at least 2 years operating, a verified address and no unresolved press scandal. Triggers on facts re-run the same function and move a failing center back to draft with a public reason.
Unknown fails exactly like unmet.
Facts marked as automated drafts never count toward the criteria.
The change log is writable only by triggers.
03
Nothing to buy, nothing to edit
Neutral ranking fails if any field a center can influence reaches the sort order. Thin location pages built on unchecked data would also promise more than the facts prove.
I dropped the sponsorship columns, and center owners have no write policy on listings. The default order is latest verification date, then name, and area and program pages count only centers with verified address and program facts.
Partner label and Google rating are display only and never reach the sort.
Area pages need 3 qualifying centers, otherwise they return a 404.
The sitemap and robots rules share one definition of what can be indexed.