What runs vinellu: one Postgres, three front doors
vinellu started as a list of wines my wife and I wanted to drink again. I told that part in Start a company again. Samara is the CEO and the founder. I'm the founder advisor who writes the code. Today vinellu has more than 173 thousand labels in the catalog and more than 9 thousand users, and a store on the shop averages R$ 800 per order and 300 orders a week.
Wine is not a cheap click. Someone paying R$ 800 for a few bottles reads the page twice, checks the label, and gives up if anything feels off. That changed how I think about almost every technical choice below. This post is what we run, why, and what I'd change.
Three front doors, one Postgres
There are three products. The app is React Native with Expo and TypeScript. It has the label scanner, the cellar, reviews, friends and the "where to buy" offers. The site, vinellu.com, is React 19 with Vite and Tailwind, hosted on Netlify. Campaigns land there, it serves the public catalog for search engines and LLMs, and it hosts our MCP server. The shop, vinellu.shop, is Next.js 16 on Vercel. Every retailer gets a storefront on a subdomain or on their own domain, plus an admin panel, and that is where the money moves.
All three talk to the same Supabase project. In front of it sits one more piece, a Cloudflare Worker that decides by rule whether each request goes to the primary or to a read replica. I'll get to why in the latency part. The catalog and the social graph live in the public schema, the shop lives in its own shop schema, and the types for the shop are generated from the app repo. People ask me why not split it into services. My answer is that a team our size can't afford the network between services. One database means one place for Row Level Security, one migration history, one backup, and a join where other companies would need an event bus. The app repo alone has more than 500 migrations and 54 architecture decision records. The ADRs are the part I'd keep if I lost everything else, because they explain why the weird code is weird.
The split is by product, not by layer. The app never charges anyone. When you tap "buy" on a label, it opens the retailer store on vinellu.shop. The site never sells either. That keeps the payment code in one repo, with one set of rules, and keeps the mobile release cycle out of the checkout.
The database does the work
Most of the logic that would usually live in workers lives in Postgres triggers. A friendship request inserts a notification row, and a trigger on that row calls an edge function that sends the push. A new wine inserts a row, and a trigger calls another function that generates its embedding with pg_net, fire and forget. Update the producer or the region and the embedding is invalidated and rebuilt. Update the alcohol percentage and nothing happens, because it doesn't change what the label means. There is no cron polling for any of this.
The AI layer is a set of Deno edge functions, more than 40 of them. The scanner identifies a label from a photo or reads a whole restaurant wine list. Hybrid search mixes full text with pgvector. Other functions enrich a new label with body, acidity, tannin and food pairings, and scrape partner stores running Shopify, VTEX and a few Brazilian platforms to keep prices fresh.
The catalog is where I paid the most for my early choices. We started from a public dataset where one row was a label times a vintage times a critic. The same wine showed up as a dozen rows, and the year inside the title confused the scanner. We split it into a label table and a vintage table, and that is what made structured enrichment possible. Then came duplicates from every scraper and every user scan, so there is a daily job where an LLM judges candidate pairs adversarially and a function merges the winners. That merge ends in a DELETE. It once took data in other tables with it in production. Since then any foreign key pointing to a wine is on delete restrict, and every new table has to be added to the merge function or the daily job breaks on purpose. I'd rather have a loud failure in a cron than a quiet hole in someone's cellar.
Infra and the latency math
The database is in us-east-2. Our users are in Brazil. From here, each round trip to Supabase costs around 182 ms, and a storefront page makes three or four of them. If you put the compute next to the user, in São Paulo, you pay that 182 ms three or four times. If you put it next to the database, you pay the trip to the user once. So the shop functions run in iad1, about 15 ms from the database, and vercel.json pins that region. It looks wrong on a map. It is right on a stopwatch, and São Paulo is also the most expensive Vercel region.
The shop gets around the 182 ms by moving compute. The app can't do that. The phone is the client, it talks to Supabase directly, and most of the time it is on a mobile network that already adds its own delay before the request even leaves the country. So I'm adding a Postgres read replica in São Paulo, as a pilot for now. We're going to move the reads that make the app feel slow to it: browsing the catalog, opening the cellar, loading a label page right after a scan. Those will stop crossing to us-east-2 and back. Writes stay on the primary in us-east-2. A review, a bottle added to the cellar or a friend request still pay the long trip, and the triggers keep firing in one place, with one source of truth. That trade works because people read much more than they write, and a save can take a moment where scrolling a list can't. The price is replication lag, so anything that has to read its own write right after saving still goes to the primary.
What decides where a query goes is a Cloudflare Worker in front of Supabase. Every request from the app passes through it, and it points each one at the primary or at the replica based on rules about where each kind of data should be fetched. A read that can live with a little lag goes to the replica in São Paulo. A write goes to the primary, and so does a read that needs to see its own write. The app doesn't know there are two databases. It asks for data, and the rules in the Worker pick the shortest path that is still correct.
The pilot starts with one table, wines. An anonymous GET /rest/v1/wines now comes back from the replica with the right data, and the p50 dropped from 242 ms on the primary to 105 ms on the replica, 57% less. Every response carries an x-vnl-db header that says which database answered, so I don't have to guess from the timings. What I cared about more than speed was the Worker being invisible to the client. A .single() still returns the object, now from the replica. A request with Range and Prefer: count still answers 206 with its content-range. A HEAD goes to the replica, a POST goes to the primary, and an invalid JWT gets the same 401 the primary gives. Routes outside the pilot, like wine_vintages, don't touch the Worker at all.
Running the shop in iad1 only works if most requests never reach a function. Cloudflare sits in front of Vercel, and the storefront is static or ISR by default. The rule written in the shop repo is blunt: between code convenience and cache, cache wins. Every time. We had a real case. The guard that checks a request is not mixing two tenants lived in the store layout and read headers(). That one call made the whole tree dynamic, so no route cache and every visit paying the database again. We didn't loosen the check. We moved it to proxy.ts, which already resolves the host before the rewrite, and it costs zero I/O there.
Multi-tenancy has a nasty failure mode with caches. store-a.vinellu.shop/wines and store-b.vinellu.shop/wines have the same path. If any cache layer keys only by path, a visitor sees another store under the wrong domain. The proxy rewrites every host to /s/<slug>/..., so the tenant is part of the path that Next and the CDN use as the key, and Cloudflare keys by hostname on top of that. Custom domains resolve through Edge Config, and the proxy never imports the database client. Images go through the Supabase transform endpoint with a custom loader, because the Vercel optimizer blows its quota with a catalog this size.
We measure with Speed Insights against a budget: LCP under 1.5 s, CLS under 0.05, INP under 200 ms. In a shop where the platform only earns when the store sells, a slow page is lost revenue, not UX debt.
The first real test was our first wine fair, with 200 people using the app at the same time inside the same hall. That is not internet scale. It is a lot of simultaneous scans, searches and saves on one event, from phones on a crowded network, at the exact moment the retailers around them are watching. The cache rules, the region choice and the reactive triggers are what we had prepared for it.
High-ticket e-commerce is a different sport
Selling R$ 800 baskets changes the threat model. Fraud looks different, chargebacks hurt more, and a confused buyer just leaves.
Payments sit behind our own interface in lib/payments. The domain never sees a vendor name. It has providerRef, never an id named after a gateway, and its own status vocabulary: pending, authorized, paid, declined, expired, canceled, refunded, chargeback. Onboarding a retailer is a state machine with a status and a next action, because every provider does KYC differently and I didn't want the contract bent around one of them. Swapping the payment provider is a new folder, not a rewrite.
The rule I care most about there: production never falls back to a mock provider because a credential is missing. The process refuses to boot. A fallback like that produces an order marked as paid that never charged anyone. The buyer gets a confirmation, the store ships the wine, and the money doesn't exist.
For fraud we use 3DS plus the anti-fraud of the payment provider. I didn't want to build our own scoring at this size. The hard part was card handling. To keep the checkout one step shorter, the card form is ours, and that puts our infrastructure in PCI scope. We accepted it and gave it one address: a single route turns the card number into an opaque token, and nothing after that route ever sees the number. A Luhn check in the charge validation exists to catch it if it leaks anywhere else.
Money has edge cases that look like product rules and aren't. There is a minimum charge, because below it the provider fee is larger than the platform fee and the split gets stuck. A partial refund doesn't change the order status, and the fee comes back cumulatively. A coupon is applied before the free shipping threshold, which reversed an earlier decision. A chargeback won after a reversal is manual support, because the state machine treats it as terminal.
Then there is the law. It's wine, so age matters, and Brazil's new ECA Digital forbids self-declaration. The "I'm over 18" checkbox exists for UX and ad networks, not for compliance. The real control is validating CPF and date of birth against an official base before payment, plus a document check on delivery. We store four fields about the verification and never the document.
Stock is the last piece. Stock changes from more than a dozen paths: sales, cancellations, imports, club cycles, bulk edits, the admin panel. Instead of remembering to log each one, a trigger records every delta in a movements table. The caller sets the reason with set_config('shop.stock_reason', 'sale', true), and that true matters. It scopes the setting to the transaction. Without it the value sticks to a pooled connection, and the next transaction on that connection belongs to another retailer, whose manual adjustment would be logged as a sale that never happened.
What I'd do differently
I'd put everything on one host. Today the site is on Netlify and the shop is on Vercel. Each choice made sense on its own day, the site came first and the shop needed Next features. Now we have two deploy pipelines, two sets of edge functions with different runtimes, two dashboards, two places to look when something is slow, and two bills. None of it is broken. It is just tax we pay every week, and it could be one.
The rest I would repeat. One Postgres, triggers over workers, compute next to the database, cache before convenience, and an ADR every time we choose something that will look wrong to the next person who opens the code. Most of the time that next person is me.