<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: daohieu91</title>
    <description>The latest articles on DEV Community by daohieu91 (@daohieu91).</description>
    <link>https://dev.to/daohieu91</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F3945316%2F4335ff53-05c2-43b9-928d-90c5b72aa6ec.png</url>
      <title>DEV Community: daohieu91</title>
      <link>https://dev.to/daohieu91</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/daohieu91"/>
    <language>en</language>
    <item>
      <title>I built a crowdsourced deals + map app for Vietnam in 6 months — stack, traffic, and what broke</title>
      <dc:creator>daohieu91</dc:creator>
      <pubDate>Fri, 22 May 2026 05:49:54 +0000</pubDate>
      <link>https://dev.to/daohieu91/i-built-a-crowdsourced-deals-map-app-for-vietnam-in-6-months-stack-traffic-and-what-broke-5ac7</link>
      <guid>https://dev.to/daohieu91/i-built-a-crowdsourced-deals-map-app-for-vietnam-in-6-months-stack-traffic-and-what-broke-5ac7</guid>
      <description>&lt;p&gt;22 days ago I shipped &lt;a href="https://cheap-map.app" rel="noopener noreferrer"&gt;cheap-map.app&lt;/a&gt; — a crowdsourced map of cheap restaurants, shops, and gas stations across Vietnam, with a multi-merchant affiliate deals aggregator bolted on. ~50 organic users a day now,&lt;br&gt;
  hockey-stick just starting.&lt;/p&gt;

&lt;p&gt;This is the build-log: real stack, real costs, three things that broke spectacularly and what I learned fixing them. If you're shipping an indie product solo, the boring infrastructure decisions matter more than the framework choice you&lt;br&gt;
   spent two weeks agonizing over (guilty).&lt;/p&gt;

&lt;p&gt;## Why this exists&lt;/p&gt;

&lt;p&gt;Vietnam has incredible cheap food and shopping — $1 phở, $0.50 cà phê sữa đá, motorbike-repair shops that fix a flat for under a dollar. But there's no good way to find them:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Google Maps optimizes for ratings, not price. The 4.7-star place is usually the tourist-priced one.&lt;/li&gt;
&lt;li&gt;Deal aggregators in VN are SEO-spam sites that copy from each other. None show &lt;em&gt;where&lt;/em&gt; a deal is.&lt;/li&gt;
&lt;li&gt;Locals know the cheap spots by word of mouth. Tourists, expats, and people who just moved to a new city are stuck.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;So: a map-first app where the community submits prices, with a deals tab pulling live affiliate offers from Shopee/Tiki/Traveloka/etc. Bilingual VI/EN. Cheap to run.&lt;br&gt;
  ## The stack&lt;/p&gt;

&lt;p&gt;I picked technologies based on "can I run this for under $30/month at 1,000 users/day."&lt;/p&gt;

&lt;p&gt;| Layer | Choice | Why |&lt;br&gt;
  |---|---|---|&lt;br&gt;
  | Frontend | Next.js 14 App Router + TypeScript | ISR for SEO-heavy programmatic pages |&lt;br&gt;
  | Styling | Tailwind + shadcn/ui | Fast, no design system to maintain |&lt;br&gt;
  | Map | Mapbox GL JS | More flexible than Google Maps, generous free tier |&lt;br&gt;
  | State | Zustand (UI) + TanStack Query (server) | Tiny bundle, good DX |&lt;br&gt;
  | Backend | Spring Boot 3.2 on Java 21 (virtual threads) | Boring, fast, JPA + PostGIS plays well |&lt;br&gt;
  | Database | Supabase (Postgres 15 + PostGIS) | Hosted PostGIS without ops |&lt;br&gt;
  | Cache | Upstash Redis (free tier) | Rate-limiting + slug caching |&lt;br&gt;
  | Images | Cloudflare R2 (free tier) | No egress fees, presigned uploads |&lt;br&gt;
  | Frontend host | Vercel Hobby | Free until traffic gets serious |&lt;br&gt;
  | Backend host | Railway | $5–10/mo for a Spring JAR |&lt;br&gt;
  | DNS / CDN | Cloudflare | $1/mo |&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Total cost at 22 days: ~$15/month&lt;/strong&gt; (Railway + Cloudflare + Mapbox). Vercel + Supabase + R2 + Upstash all still on free tier.&lt;/p&gt;

&lt;p&gt;The big architectural call: &lt;strong&gt;monolith, not microservices&lt;/strong&gt;. One Spring app, feature-based packages (&lt;code&gt;auth/&lt;/code&gt;, &lt;code&gt;location/&lt;/code&gt;, &lt;code&gt;deal/&lt;/code&gt;, &lt;code&gt;ranking/&lt;/code&gt;...), one database. I'll extract a service the day traffic justifies it, not before.&lt;/p&gt;

&lt;p&gt;The other big call: &lt;strong&gt;REST, not GraphQL&lt;/strong&gt;. Simpler caching, simpler clients, simpler future mobile app.&lt;/p&gt;

&lt;p&gt;## Three things that broke&lt;/p&gt;

&lt;p&gt;### 1. Vercel ISR Writes blew up at 50% of the monthly free-tier cap&lt;/p&gt;

&lt;p&gt;About day 14, I checked Vercel's usage dashboard. ISR Writes — the number of times Next.js regenerates a statically-cached page — was at 50% of the 200k/month free-tier limit. Three weeks in. The math projected an overage by week 5.&lt;/p&gt;

&lt;p&gt;I have 43k location pages, all ISR'd. Plus 90+ deal hub pages. Plus 360 city × category cells. With &lt;code&gt;revalidate: 3600&lt;/code&gt; (1 hour) on the location pages, every page got re-generated 24 times a day → 1M+ writes/month theoretical. Bots were&lt;br&gt;
  hitting them faster than humans.&lt;/p&gt;

&lt;p&gt;The fix was one config change spread across six route files:&lt;/p&gt;

&lt;p&gt;// /location/[slug]/page.tsx&lt;br&gt;
  export const revalidate = 604800;  // was 3600 (1h) → now 7d&lt;/p&gt;

&lt;p&gt;// /deals, /deals/category/[slug], /san-sale/[merchant], etc&lt;br&gt;
  export const revalidate = 21600;   // was 3600 → now 6h&lt;/p&gt;

&lt;p&gt;// /san-sale/today  ← KEPT at 3600&lt;br&gt;
  export const revalidate = 3600;&lt;/p&gt;

&lt;p&gt;The last line is the interesting one. /san-sale/today has a date-stamped H1 ("Săn sale hôm nay 22/05/2026") engineered for "deals today" SEO. Bumping its revalidate would either (a) break the date-fresh signal Google uses for "today"&lt;br&gt;
  queries, or (b) make my hourly in sitemap.xml a false claim. So that one page eats writes; the other 43k+ don't.&lt;/p&gt;

&lt;p&gt;Estimated reduction: 226k writes/month → 36k. ~85% drop.&lt;/p&gt;

&lt;p&gt;Lesson: check your platform's metered dashboards every week, not when you hit the warning email. ISR revalidate defaults rarely match your actual content's update cadence.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Samsung's affiliate URLs were wrapped in a Commission Junction tracker — and that broke the entire affiliate deeplinker&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The deals aggregator pulls product feeds from AccessTrade Vietnam (a VN affiliate network). For most merchants, you take the product URL from their CSV feed, wrap it in AccessTrade's redirect&lt;br&gt;
  (&lt;a href="https://go.isclix.com/deep_link/" rel="noopener noreferrer"&gt;https://go.isclix.com/deep_link/&lt;/a&gt;?url=), and you're done. Clicks attribute to your publisher ID, you earn commission.&lt;/p&gt;

&lt;p&gt;Samsung's feed broke this. Every Samsung click returned HTTP 500.&lt;/p&gt;

&lt;p&gt;I dug in. The Samsung CSV URLs looked like this:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://clk.omgt3.com/?AID=12345&amp;amp;r=samsung.com/vn/smartphones/galaxy-s24" rel="noopener noreferrer"&gt;https://clk.omgt3.com/?AID=12345&amp;amp;r=samsung.com/vn/smartphones/galaxy-s24&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;That's not a Samsung URL. That's a Commission Junction tracker URL with the actual Samsung URL stuffed in the r= query param. CJ is a separate affiliate network — Samsung apparently runs through both, and AccessTrade is pulling&lt;br&gt;
  Samsung's CJ-wrapped feed.&lt;/p&gt;

&lt;p&gt;AccessTrade's deeplinker saw the clk.omgt3.com hostname, decided "this is already a tracker," and routed it to a special /deep_link/v2/2306/ path that's supposed to chain trackers. That endpoint is broken — silent 500.&lt;/p&gt;

&lt;p&gt;The fix is a 20-line static helper:&lt;/p&gt;

&lt;p&gt;public static String unwrapCjTracker(String url) {&lt;br&gt;
      if (url == null || !url.contains("clk.omgt3.com")) return url;&lt;br&gt;
      try {&lt;br&gt;
          URI uri = URI.create(url);&lt;br&gt;
          String query = uri.getQuery();&lt;br&gt;
          if (query == null) return url;&lt;br&gt;
          for (String pair : query.split("&amp;amp;")) {&lt;br&gt;
              int eq = pair.indexOf('=');&lt;br&gt;
              if (eq &amp;gt; 0 &amp;amp;&amp;amp; "r".equals(pair.substring(0, eq))) {&lt;br&gt;
                  String inner = URLDecoder.decode(pair.substring(eq + 1), UTF_8);&lt;br&gt;
                  return inner.startsWith("http") ? inner : "https://" + inner;&lt;br&gt;
              }&lt;br&gt;
          }&lt;br&gt;
      } catch (Exception e) { /* fall through */ }&lt;br&gt;
      return url;&lt;br&gt;
  }&lt;/p&gt;

&lt;p&gt;Called on every URL before it goes into the affiliate wrapper. No-op for the 99% that aren't CJ-wrapped. Samsung clicks work now.&lt;/p&gt;

&lt;p&gt;Lesson: when an affiliate network "auto-detects" something about a URL, assume it will do the wrong thing for at least one merchant. Write the unwrap helper proactively.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;I thought I had an egress problem. I had a caching problem.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Supabase's egress dashboard started looking ugly: ~8 GB/day. The free tier caps at 5 GB/month. I had a week before getting throttled.&lt;/p&gt;

&lt;p&gt;My first instinct was "compress everything." I turned on server.compression=true in Spring Boot. Watched the dashboard. Egress didn't move.&lt;/p&gt;

&lt;p&gt;This is when I realized I'd misunderstood the topology:&lt;/p&gt;

&lt;p&gt;Browser  ←--gzip--  Next.js  ←--no gzip--  Spring API  ←--no gzip--  Supabase&lt;/p&gt;

&lt;p&gt;Spring's HTTP compression compresses outbound HTTP — Spring → Next.js or Spring → browser. It does nothing to the inbound traffic from Supabase, which is the raw JDBC protocol over a Supabase connection. That JDBC channel is what the&lt;br&gt;
  egress dashboard measures.&lt;/p&gt;

&lt;p&gt;The real culprit was a single endpoint: GET /api/v1/locations/slugs — used by the Next.js sitemap to list every location URL. 16 MB uncompressed. Next.js was calling it on every sitemap request. No cache. Every Bingbot crawl, every&lt;br&gt;
  Ahrefs scrape, every sitemap ping = another 16 MB from Postgres → Railway.&lt;/p&gt;

&lt;p&gt;Fix was two lines:&lt;/p&gt;

&lt;p&gt;@Cacheable("active-slugs")  // 6h Redis TTL&lt;br&gt;
  public List getActiveSlugs() { ... }&lt;/p&gt;

&lt;p&gt;// Next.js sitemap&lt;br&gt;
  const res = await fetch(SLUGS_URL, { next: { revalidate: 21600 } });  // 6h&lt;/p&gt;

&lt;p&gt;8 GB/day → 200 MB/day overnight.&lt;/p&gt;

&lt;p&gt;Lesson: before optimizing what you think the bottleneck is, instrument the actual data path. HTTP gzip is great, but it can't help you on a wire it isn't on.&lt;/p&gt;

&lt;p&gt;Traffic numbers — day 22&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;~50 organic users/day (hockey-stick juuust starting last week)&lt;/li&gt;
&lt;li&gt;~921 cumulative users across 22 days&lt;/li&gt;
&lt;li&gt;~43k indexable location pages + 90 deal hubs + 360 city×category cells + ~205 tool-hub pages&lt;/li&gt;
&lt;li&gt;All on free/cheap tiers — total infra spend ~$15/mo&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;What worked for SEO:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Programmatic city × category landing pages&lt;/li&gt;
&lt;li&gt;Full JSON-LD (BreadcrumbList, ItemList, FAQPage, LocalBusiness with type-narrowing — Restaurant / Bakery / GasStation etc., not generic LocalBusiness)&lt;/li&gt;
&lt;li&gt;Bilingual hreflang VI/EN&lt;/li&gt;
&lt;li&gt;A simple Purchasing Power calculator for the Tools Hub — converts your salary into cost-of-living-adjusted equivalent across countries. Built it as a side surface, ended up being one of the highest-engagement pages.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;What didn't work yet:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Backlinks. Zero outreach so far. That's the next 30 days.&lt;/li&gt;
&lt;li&gt;Affiliate conversion tracking. The data's flowing, the GA4 event isn't wired yet.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;What I'd do differently&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Set up Vercel/Supabase usage alerts on day 1, not "when the first warning email arrives." Both have webhook integrations I ignored.&lt;/li&gt;
&lt;li&gt;Write the affiliate URL unwrapper before integrating the first merchant. I would have caught Samsung in 5 minutes instead of 2 hours of "why is this one merchant returning 500."&lt;/li&gt;
&lt;li&gt;Cache anything that returns &amp;gt;1 MB before pushing it to prod. Even on staging, with one user (me), the moment a bot finds the sitemap you're cooked.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;What's next&lt;/p&gt;

&lt;p&gt;Three things in the next 30 days, in order of leverage:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Google Search Console audit — find the queries ranking position 11–30 and pull them onto page 1 with small content additions&lt;/li&gt;
&lt;li&gt;Backlinks. 3 quality ones. (This post is one of them, hi!)&lt;/li&gt;
&lt;li&gt;Wire affiliate-click tracking to GA4 so I actually know which deals convert&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;If you want to poke at the live site, it's cheap-map.app — try the Purchasing Power calculator or browse today's deals. Feedback welcome in the comments — especially the "what would you have done differently" kind.&lt;/p&gt;

</description>
      <category>showdev</category>
      <category>webdev</category>
      <category>nextjs</category>
      <category>java</category>
    </item>
  </channel>
</rss>
