Self-initiated product · design, code and operations
The Social Hub
Plans that actually happen
A community events platform with one stubborn rule: every event has a minimum crowd. If not enough people say yes before the deadline, the event cancels itself and nobody wastes their evening. I designed it, built it, deployed it, and broke it a few times on the way — alone, in public, on my own domain.
Why I gave myself this problem
Twenty years of product work teaches you how to brief engineers extremely well. It does not teach you what it feels like when your own deploy silently drops half its traffic at three in the afternoon. I wanted that feeling. So instead of another prototype that dies in Figma, I picked a product idea with real moving parts — accounts, money, scheduling, two languages, search engines — and gave myself one condition: it has to be publicly reachable on its own domain, not a demo I show on my laptop.
The learning goal
Newest stack, no training wheels
React 19, Vite, tRPC 11 with end-to-end type safety, Drizzle ORM, a hosted Postgres, Stripe Connect, server-side SEO for a client-rendered app. Each of those is a blog post. Wiring all of them into one product that a stranger can use is a different exercise entirely.
The product goal
A mechanic worth building
Every events platform has the same quiet failure: five people say maybe, two show up, the host stops hosting. A minimum crowd with a deadline turns that from an awkward evening into a clean cancellation — and gives everyone a reason to invite one more person.
One rule, visible everywhere
The threshold is not a setting buried in a form. It is the interface. A card does not say "3 attending", it says how many people are still missing and how long they have. An event page leads with the same sentence. The progress bar is the product promise rendered in eight pixels of colour, and every state in the system — needs people, almost there, happening, cancelled — is a state a visitor can read at a glance.
Browsing: every card counts down to its minimum
Deciding: the event page says exactly what is missing
The threshold logic lives in one shared contract file used by both the server and the browser, and it deliberately returns translation keys rather than sentences. That single decision is why adding a second language later cost days instead of weeks — the rules never knew what language they were speaking.
From a platform export to a repository I own
The first version was generated on an AI app platform. It looked finished, which is the most expensive kind of finished. Getting it off that platform was the first real week of work, and it is the part of the story I find most useful: everything that made it convenient there made it non-portable everywhere else.
Ripped out the platform login
Auth
The hosted OAuth was welded to the platform. It came out completely and was replaced with signed JWT session cookies, scrypt password hashing, and later Google Sign-In verified server-side — because a Google button that renders proves nothing at all.
Converted the schema to Postgres
Database
The schema was written in a MySQL dialect. Nine tables were rewritten for Postgres with Drizzle, raw SQL requoted, migrations generated, and demo data seeded so the thing had something to show while it was still empty.
Made deployment boring
Infrastructure
Own private GitHub repo, Supabase Postgres in Frankfurt, a Render blueprint, push to main equals deploy. Boring is the goal: the interesting parts of a side project should be the product, not the release ritual.
How it is built
Client and server are one TypeScript project and one Node process. The browser calls tRPC procedures directly, so a renamed field in the database surfaces as a red squiggle in a React component rather than as a runtime surprise in production. There is no separate API contract to keep in sync, because there is no separate API contract.
The interface is built on a token-driven component layer — shadcn/ui primitives on Tailwind, with motion handled centrally so animation stays consistent instead of being reinvented per screen. Old habits: I gave a personal project a design system, and it paid for itself the first time the mobile shell needed rebuilding.
Shared business rules — thresholds, deadlines, filter logic — sit in a contracts folder imported by both sides. One implementation, two runtimes, no drift.
Shared contracts sit between client and server — one rulebook, imported twice.
The bugs that turned it into a product
A side project becomes a real product the moment it starts failing in ways your laptop cannot reproduce. These are the four I keep telling people about, because each one was invisible locally and expensive in production.
Half the traffic vanished
Symptom: randomly broken images
The server bound to an IPv6 socket instead of 0.0.0.0, so the host's proxy quietly discarded roughly half of all requests. Locally it was invisible, because macOS resolves both stacks happily. The visible symptom was images failing at random — the kind of bug you blame on the CDN, the browser and your own eyesight before you blame the socket.
Google was about to be told the site lives on localhost
Symptom: none, until it is too late
While wiring canonical URLs for the domain move I found the public URL falling back to http://localhost:3000. One deploy without the environment variable set would have published localhost as the canonical, in every Open Graph tag and across the whole sitemap. Caught the day before the move; the production default is now the real domain.
A fake event could have collected the money
Symptom: a passing test suite
Payouts were released when the start date had passed — not when the event had actually taken place. Create an event, let the calendar do its thing, get paid. Now release depends on the event ending, a holding period, no open report and no cancellation. Found while writing the payment flow, before a single real franc touched it.
Housekeeping was deleting the evidence
Symptom: a tidy database
A cleanup job hard-deleted bookings a day after their deadline. Harmless with demo data; with real payments it would have destroyed refund proof and chargeback records that Swiss law expects you to keep for ten years. Replaced with a soft delete. "Tidy" is not a requirement — retrievable is.
Honourable mention. One server file contained a real NUL byte used to mask HTML comments. It worked perfectly, and it made every tool treat the file as binary — so grep returned nothing at all instead of complaining. I lost a good half hour to a file that was lying by staying silent.
Money I deliberately never hold
Paid events meant deciding where ticket money sits between purchase and event. The comfortable answer is "our account". The correct answer, in Switzerland, is nowhere near it: holding other people's money can count as taking deposits or as financial intermediation, and that is a regulated activity with a regulator attached.
So the architecture is separate charges and transfers. Buyers are charged on the platform account, the funds stay in the payment provider's balance, and they only ever move in two directions: refunded to the buyer, or transferred to the organiser after the event has actually happened. The regulated party stays the payment provider.
Three invariants hold the whole thing together: only a signed webhook can turn a booking into a paid ticket, the demo checkout disables itself the moment real keys exist, and every operation is idempotent because webhooks arrive more than once. The card form itself was replaced with a hosted payment element, so raw card numbers never touch my DOM.
Built, then switched off. The entire payment layer — onboarding, payment intents, signed webhooks, refunds, payout batches, dispute reporting — ships in the codebase and stays dormant without a secret key. Until the company registration number comes through, the platform runs a clearly labelled demo checkout instead.
Shipping a finished feature in the off position is an underrated move. The alternative is a half-built integration that has to be remembered six weeks later.
Search and sharing for an app that renders in the browser
A single-page app has a specific blind spot: the crawlers that generate link previews do not run JavaScript. Paste an event link into a chat and you get an empty box — which, for a product whose entire growth loop is "invite one more person", is not a cosmetic problem.
Meta injected per route
The server writes title, description, canonical and Open Graph tags into the HTML shell before it leaves, so every route has a real preview without giving up client rendering.
Event structured data
Event pages carry JSON-LD in the schema.org Event format — the prerequisite for event rich results, and the single largest organic lever this product category has.
Robots, sitemap, share card
A dynamic bilingual sitemap with hreflang, finished before the first crawl so the structure never had to be relearned, plus a hand-built 1200×630 share image.
One find in this block was pure comedy: requests that did not send text/html in their accept header received a JSON 404. Several link unfurlers send wildcards, so the previews that mattered most were being handed an error page by a server doing exactly what it was told.
German owns the short URLs
The market is German-speaking, so the product had to be. The tempting shortcut is a few German landing pages bolted onto an English app. I went for full internationalisation instead, with one opinionated decision: German owns the bare paths and English moved to /en/. The short, shareable URL should be the one most readers can actually read.
The move that kept it small: React Router's basename carries the language prefix, so not a single link in the application had to be touched. Eleven hard-coded date formatters were taught to read the active language — until then, the German site cheerfully printed English weekday names and twelve-hour times on every card.
What I would tell myself earlier
- Keys, not sentences. Shared logic should return translation keys. Anything that returns a sentence has quietly chosen a language for the whole system.
- Hard-coded English hides in states. Not in headlines — in toasts, empty states, dialogs and error messages. Those are the last screens anyone reviews.
- Ship hreflang before the crawl. Teaching a search engine the right structure once is cheaper than making it unlearn the wrong one.
- Review every change twice. Once in each language. A translation audit is not done when the home page looks right.
Mobile is not a narrower desktop
The brief I gave myself was "have a quick look at the mobile view". The audit was less quick than hoped: the navigation collided with itself on every page, one event card was around 450 pixels tall so exactly one event fitted on a screen, the explore filters scrolled sideways into invisibility, and a single page carried 33 tap targets under the 44-pixel minimum. It was a desktop site holding its breath.
Rebuilt mobile home: search pulled up, hero condensed
Event detail: facts consolidated, RSVP bar pinned in reach
Every safe-area padding was worth nothing
iPhone only
The viewport meta tag was missing viewport-fit=cover, which silently turns every safe-area inset in the project into zero. The carefully built RSVP bar was sitting underneath the home indicator on the exact devices it was designed for.
Inputs zoomed in and never came back
iPhone only
Form fields at 14 pixels make iOS Safari zoom the page on focus — and it does not zoom back out. Two lines of CSS, and a reminder that a narrow browser window is not a phone. Both bugs are invisible on a desktop at 375 pixels wide.
Verification did not happen by squinting at screenshots. I measured it in the browser: zero overflowing elements, document width exactly 375 pixels, every filter segment inside the visible area. After the deploy I searched the delivered bundle on the live domain instead of trusting the build log — production is only correct when you have checked what actually shipped.
Where it stands, honestly
It is live, bilingual, indexable and payment-ready. It is also a private project with demo data in it, and pretending otherwise would defeat the point of writing this down.
Done and running
- Live on its own domain, German by default, English under /en/
- Email and Google sign-in, both verified server-side
- Event creation with image upload, groups, RSVPs, threshold cancellation
- Server-side meta, event structured data, bilingual sitemap
- Purpose-built mobile experience, consent-gated analytics, privacy and terms in both languages
Deliberately not done
- Payments stay dormant until the company registration number arrives
- Around 570 strings in signed-in and admin areas are still English — a running audit, not a finished one
- City and category landing pages, the actual ranking lever, do not exist yet
- Demo seed data still shares a database with development; a wipe comes before real traffic
What I took away. Not the frameworks — those change every eighteen months anyway. What stuck is how differently you review a design when you are also the person who has to migrate the database at eleven at night: fewer states invented for their own sake, more decisions made once and shared, and a much shorter path between "that looks wrong" and "that is fixed". I brief engineers better now, mostly because I have finally been the one on the other end of my own briefs.