Building Names That Mean Something Else
/ 8 min read
Table of Contents
Say “Justin Case” out loud and it is a name. Read it and it is a phrase. There are hundreds of these, people keep inventing new ones, and until May I had nowhere to put them. So I built names-that-mean-something-else.com: submit a name, vote it up or down, browse the best ones. No accounts, no analytics, one vote per person.
The current leaders give a feel for it: Dan DeLyon, Shirley Thistlewurk, Howie Douin, Pat Engranted, Noah Zark, Patty O’Door. Some land instantly, some need a second read, and the vote counts sort out which is which.
The stack, and why it is small
I wanted the whole thing to be one Go binary and a Postgres database, with the same deployment shape as my other projects so I did not have to think about hosting. That meant:
- Go 1.25 with Gin and GORM for the server. Every list request is one SQL query that aggregates votes per name and projects the current viewer’s own vote onto each row.
- HTMX over Go
html/templatefor the UI. The page is a form and a list. Voting, submitting and filtering are all HTMX requests that swap a rendered partial back in. There is no bundler, no Node, and the production image is Alpine plus the binary plus a static folder. - Postgres 16 with a unique index on
lower(text)so “justin case” and “Justin Case” are the same submission.
The first working version was built and deployed in a single evening. Most of the interesting work came afterwards, in the parts that stop a public, anonymous, write-anything site from turning into a mess.
One vote per person, no login
Anonymous voting has a built-in tension. Voters should not have to sign up, but each person should get one vote per name, and a cleared cache should not hand them a fresh ballot every time.
The approach is a long-lived HttpOnly cookie holding a random ID. The server never stores that ID directly. It HMACs it with a secret salt and uses the hash as the voter identity everywhere: on votes, on flags, and on submissions so the page can badge the names you added as “yours”.
Two deliberate choices in there:
- IP address and User-Agent are not part of the hash. Early on I planned to mix them in. In practice that would orphan someone’s votes every time they switched from wifi to mobile data or their browser updated. Stability beat a marginal amount of extra abuse resistance.
- Browser fingerprinting is only a seed. The page loads FingerprintJS and sends the visitor ID as a header on the first request. If there is no cookie yet, that ID becomes the cookie value. So if you clear cookies on the same device, you tend to get the same identity back. If the fingerprint script is blocked or slow, a random ID is used instead and nothing else changes.
Votes live in a table with a unique index on (name_id, voter_hash). Changing your mind is an upsert on that index rather than a find-then-update, so two quick taps on a phone cannot race into a duplicate-key error:
v := models.Vote{NameID: n.ID, VoterHash: voterHash, Value: int8(body.Value)}database.DB.Clauses(clause.OnConflict{ Columns: []clause.Column{{Name: "name_id"}, {Name: "voter_hash"}}, DoUpdates: clause.AssignmentColumns([]string{"value", "updated_at"}),}).Create(&v)Keeping the list clean
Anything a stranger can type into a box will eventually receive links, phone numbers and slurs. The submission path runs a few gates before anything is saved:
- Whitespace is collapsed and the length is checked (1 to 80 characters).
- A profanity filter rejects the obvious stuff outright.
- Regexes catch URLs, bare domains, email addresses,
@handlesand anything that looks like a phone number. - The case-insensitive duplicate check returns a friendly “already submitted” instead of a database error.
- Per-voter rate limits: roughly four submissions a minute, twenty votes in a burst, a handful of flags.
That still leaves the names that are technically clean but are just a crude joke. For those the site uses the crowd. Every row has a flag button. After three distinct voters flag a name it drops out of the public list into a review queue, where I can dismiss it (back to the list, flags cleared), confirm it (moved to a separate offensive list) or remove it.
The offensive list is not deleted. It sits behind a disclosure in the footer with a checkbox you have to tick to see it. The reason is practical: I want to be able to hand the site to anyone, including kids, and have the default view be the clean one. If you want the full list, it is one click away.
Top, controversial, new
Three sorts. “Top” is net score. “New” is creation date. “Controversial” needs both sides to be arguing, so it ranks by the smaller of the up and down counts scaled by the log of the total:
ORDER BY (LEAST(up_count, down_count)::float * LN(up_count + down_count + 1)) DESCA name with 10 up and 10 down beats one with 2 and 2, and both beat one with 40 up and 1 down.
The timeframe filter (today, month, year, all time) does something slightly different depending on the sort. Under “top” and “controversial” it windows the votes, so “top this month” means the names people liked this month, not the names submitted this month. Under “new” it windows the creation date instead, which is what you would expect.
Live updates
If two people are looking at the list and one votes, the other should see it. The server keeps a websocket hub and, whenever anything changes, broadcasts a tiny envelope:
{"type":"names.changed"}That is the entire protocol. The client does not receive the new data, it just re-runs whatever GET it last made, with its own cookie, so the per-viewer projection (my vote, my flag, the current sort and filter, how many pages I have loaded) stays correct without the server tracking any of it. Bursts are coalesced into a single refresh, and the refresh waits if you are mid-click so the list does not jump out from under your thumb.
On deploy the old process closes every socket before shutting down. Browsers reconnect with backoff to the new process and pull a fresh list, so a rolling update looks like a brief blip rather than a hung page.
Read it aloud
The whole joke depends on pronunciation, so every row has a speaker button that uses the browser’s speech synthesis, and there is a “read list” button that goes through the visible names in order. There is also a preview button next to the submit box so you can hear your own entry before you post it.
That feature shaped the guidelines more than anything else. The rule on the page is that the name should be an ordinary phrase that sounds like a name when read the obvious way. No “you have to say it like this” gimmicks. If a robot voice reads it flat and the joke still lands, it is a good one.
Deploying it
The repository has two workflows on a self-hosted runner at home:
- Pull request opened or updated builds the image and pushes it to a private registry, tagged with the PR number.
- Merge to main looks up which PR produced the merge commit, pulls that exact tag, creates a versioned set of Docker secrets, and runs
docker stack deploy. The deploy step then polls the swarm until every replica reports ready, or fails after a minute.
The stack is Postgres plus the backend, pinned to one node, behind Traefik with a Cloudflare-issued certificate. Updates are start-first with automatic rollback, so the new container is healthy before the old one is stopped. Since the volume holds the database and the app migrates its own schema on boot, a deploy is one image swap and nothing else.
The first bug after launch was a cache. Cloudflare and browsers held onto the stylesheet and script across a deploy, so people were getting the new HTML with the old JavaScript. The fix was small: the process stamps its start time into every static asset URL as a ?v= parameter, static files are served as immutable for a year, and everything else is served with no-store. New deploy, new URLs, no stale assets.
What I would tell someone building the same thing
- HTMX plus server templates is enough for a site like this, and the absence of a build step is worth more than it sounds. The whole front end is one script file.
- Decide what identity means before writing the vote table. Cookie versus fingerprint versus IP is a product decision, and every later feature (mine, unvoted only, flag once) depends on it.
- Plan for the crowd to moderate, and give yourself a queue instead of a delete button. Most of what gets flagged is borderline, and “confirm as offensive but keep it” turned out to be the decision I make most.
The code is public if you want to poke at it or send a name in through a pull request rather than the box.