<?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: j209509</title>
    <description>The latest articles on DEV Community by j209509 (@j209509).</description>
    <link>https://dev.to/j209509</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%2F3932363%2Fe032070e-709a-4e95-8213-0d0bed97a427.png</url>
      <title>DEV Community: j209509</title>
      <link>https://dev.to/j209509</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/j209509"/>
    <language>en</language>
    <item>
      <title>Adding i18n to a 14,000-page Next.js site with next-intl — what actually broke</title>
      <dc:creator>j209509</dc:creator>
      <pubDate>Fri, 15 May 2026 09:09:16 +0000</pubDate>
      <link>https://dev.to/j209509/adding-i18n-to-a-14000-page-nextjs-site-with-next-intl-what-actually-broke-1lne</link>
      <guid>https://dev.to/j209509/adding-i18n-to-a-14000-page-nextjs-site-with-next-intl-what-actually-broke-1lne</guid>
      <description>&lt;p&gt;&lt;em&gt;I run &lt;a href="https://bacotto.com/en" rel="noopener noreferrer"&gt;bacotto&lt;/a&gt;, a B2B sales-list SaaS for the Japanese market. It started Japanese-only. Last week I made the whole thing bilingual — landing page, dashboard, auth, emails, ~17 blog posts, and 4,364 programmatic SEO pages. Here's the real write-up: the architecture, and the things that broke.&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  The setup
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Next.js 15 (App Router), TypeScript, React 19&lt;/li&gt;
&lt;li&gt;~1,565 hard-coded Japanese strings across 40+ files&lt;/li&gt;
&lt;li&gt;4,364 statically-generated programmatic SEO pages (&lt;code&gt;force-static&lt;/code&gt;, &lt;code&gt;dynamicParams = false&lt;/code&gt;)&lt;/li&gt;
&lt;li&gt;Goal: Japanese stays the default at &lt;code&gt;/&lt;/code&gt;, English served from &lt;code&gt;/en&lt;/code&gt;, no SEO regression&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Why next-intl
&lt;/h2&gt;

&lt;p&gt;I evaluated next-intl, Paraglide, and rolling my own. next-intl won because:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;App Router-native, works with Server Components without "use client" thrash&lt;/li&gt;
&lt;li&gt;Supports static generation — critical for the 4,364 &lt;code&gt;force-static&lt;/code&gt; pages&lt;/li&gt;
&lt;li&gt;Built-in middleware for locale negotiation + hreflang helpers&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Paraglide is leaner, but with only two locales its tree-shaking edge didn't matter. Rolling my own across that much SEO surface was a trap.&lt;/p&gt;

&lt;h2&gt;
  
  
  URL strategy: &lt;code&gt;localePrefix: 'as-needed'&lt;/code&gt;
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// src/i18n/routing.ts&lt;/span&gt;
&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;routing&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;defineRouting&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;locales&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;ja&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;en&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
  &lt;span class="na"&gt;defaultLocale&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;ja&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;localePrefix&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;as-needed&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;localeDetection&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;as-needed&lt;/code&gt; means the default locale (ja) keeps its bare URLs — &lt;code&gt;/&lt;/code&gt;, &lt;code&gt;/list/...&lt;/code&gt;, &lt;code&gt;/blog/...&lt;/code&gt; — while English gets &lt;code&gt;/en/...&lt;/code&gt;. This matters: I had ~4,364 Japanese pages already indexed. Forcing them all to &lt;code&gt;/ja/...&lt;/code&gt; would have triggered a mass 301 chain and bled link equity.&lt;/p&gt;

&lt;h2&gt;
  
  
  The first thing that broke: auto-redirect would have killed my SEO
&lt;/h2&gt;

&lt;p&gt;next-intl can auto-detect locale and redirect. I turned it &lt;strong&gt;off&lt;/strong&gt; (&lt;code&gt;localeDetection: false&lt;/code&gt;).&lt;/p&gt;

&lt;p&gt;Why: Googlebot crawls from US IPs with &lt;code&gt;Accept-Language: en&lt;/code&gt;. If &lt;code&gt;/&lt;/code&gt; auto-redirects en-preferring clients to &lt;code&gt;/en&lt;/code&gt;, Googlebot gets bounced off the entire Japanese tree and the Japanese SERP presence collapses. Auto-redirect on the root of a multilingual site is a footgun. I show a manual "English / 日本語" switch instead.&lt;/p&gt;

&lt;h2&gt;
  
  
  Restructuring into &lt;code&gt;[locale]&lt;/code&gt;
&lt;/h2&gt;

&lt;p&gt;Everything under &lt;code&gt;src/app/&lt;/code&gt; moved into &lt;code&gt;src/app/[locale]/&lt;/code&gt; — except &lt;code&gt;api/&lt;/code&gt;, &lt;code&gt;sitemap.ts&lt;/code&gt;, &lt;code&gt;robots.ts&lt;/code&gt;, and route handlers, which are locale-agnostic. The &lt;code&gt;[locale]/layout.tsx&lt;/code&gt; becomes the de-facto root layout (&lt;code&gt;&amp;lt;html lang={locale}&amp;gt;&lt;/code&gt;).&lt;/p&gt;

&lt;p&gt;The gotcha: &lt;code&gt;setRequestLocale(locale)&lt;/code&gt; must be called in every statically-rendered route, or next-intl forces the whole tree dynamic. Miss it on one page and your &lt;code&gt;force-static&lt;/code&gt; silently becomes &lt;code&gt;force-dynamic&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  The build error that only showed up in production
&lt;/h2&gt;

&lt;p&gt;Local &lt;code&gt;tsc --noEmit&lt;/code&gt; was clean. The Vercel build logged this 200+ times:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Error: MISSING_MESSAGE: blog.detail.readMinutes (en)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The blog detail page called &lt;code&gt;t('readMinutes')&lt;/code&gt; from the &lt;code&gt;blog.detail&lt;/code&gt; namespace, but the key only existed under &lt;code&gt;blog.index&lt;/code&gt;. TypeScript doesn't validate message-key existence against the JSON by default — so this only surfaced at static-generation time, per page. Lesson: a missing-key check belongs in CI, or use next-intl's &lt;code&gt;Formats&lt;/code&gt;/augmented types so the keys are type-checked.&lt;/p&gt;

&lt;h2&gt;
  
  
  hreflang, sitemap, and not duplicate-flagging yourself
&lt;/h2&gt;

&lt;p&gt;Every page emits:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight tsx"&gt;&lt;code&gt;&lt;span class="nx"&gt;alternates&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nl"&gt;languages&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;ja&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;https://bacotto.com/...&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;en&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;https://bacotto.com/en/...&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;x-default&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;https://bacotto.com/...&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="p"&gt;},&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;For the programmatic pages, in phase 1 the English variant rendered the &lt;em&gt;same&lt;/em&gt; Japanese business data with an English UI shell. I deliberately did &lt;strong&gt;not&lt;/strong&gt; list those &lt;code&gt;/en/list/*&lt;/code&gt; URLs in the sitemap until they had genuinely localized content — otherwise Search Console flags them as duplicates. Only once the industry/region names and page copy were actually translated did the English programmatic URLs go into the sitemap.&lt;/p&gt;

&lt;h2&gt;
  
  
  IME corruption when automating content entry
&lt;/h2&gt;

&lt;p&gt;Tangential but worth knowing: when scripting Japanese text into rich-text editors (ProseMirror, CodeMirror), typing character-by-character through a synthetic IME path corrupts multibyte input (リスト → リスツノ). The fix is to dispatch a real &lt;code&gt;ClipboardEvent('paste')&lt;/code&gt; with &lt;code&gt;text/html&lt;/code&gt; or &lt;code&gt;text/plain&lt;/code&gt; data instead of simulating keystrokes.&lt;/p&gt;

&lt;h2&gt;
  
  
  Emails are part of i18n too
&lt;/h2&gt;

&lt;p&gt;Easy to forget. The welcome email, subscription receipt, top-up receipt, and day-1 onboarding nudge all needed locale branching. The tricky part: the Stripe webhook and the cron job have no request cookie to read locale from — so the user's locale has to be &lt;strong&gt;persisted&lt;/strong&gt; on the account record at signup (from the &lt;code&gt;NEXT_LOCALE&lt;/code&gt; cookie / &lt;code&gt;Accept-Language&lt;/code&gt;), and every downstream sender reads it from there.&lt;/p&gt;

&lt;h2&gt;
  
  
  Result
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;bacotto.com&lt;/code&gt; stays Japanese; &lt;code&gt;bacotto.com/en&lt;/code&gt; is a fully English site — LP, dashboard, auth, 17 blog posts, 4,364 programmatic pages. Japanese SEO untouched, English surface added on top.&lt;/p&gt;

&lt;p&gt;If you're doing a non-English-market product and thinking about going bilingual: the i18n library is the easy 20%. The 80% is SEO discipline — URL strategy, hreflang, not auto-redirecting bots, and not duplicate-flagging your own pages.&lt;/p&gt;

&lt;p&gt;Happy to answer questions. The product itself (&lt;a href="https://bacotto.com/en" rel="noopener noreferrer"&gt;bacotto&lt;/a&gt;) generates B2B sales lists for Japan — type an industry + region, get 100 leads with phone/email/Instagram/LINE in 3 minutes.&lt;/p&gt;

</description>
      <category>nextjs</category>
      <category>typescript</category>
      <category>webdev</category>
      <category>i18n</category>
    </item>
    <item>
      <title>Building a B2B Sales-List SaaS with Next.js + Vercel KV + Google Places API — Full architecture deep dive</title>
      <dc:creator>j209509</dc:creator>
      <pubDate>Fri, 15 May 2026 04:58:30 +0000</pubDate>
      <link>https://dev.to/j209509/building-a-b2b-sales-list-saas-with-nextjs-vercel-kv-google-places-api-full-architecture-2gha</link>
      <guid>https://dev.to/j209509/building-a-b2b-sales-list-saas-with-nextjs-vercel-kv-google-places-api-full-architecture-2gha</guid>
      <description>&lt;h2&gt;
  
  
  Intro
&lt;/h2&gt;

&lt;p&gt;I built a B2B sales-list-generation SaaS as a solo/small-team developer and have been running it in production. The product is &lt;a href="https://bacotto.com/en" rel="noopener noreferrer"&gt;bacotto&lt;/a&gt; — type in an industry and a region, and it returns 100 enriched leads in 3 minutes (address, phone, email, Instagram handle, LINE official account, Google reviews, plus a "still operating" classification).&lt;/p&gt;

&lt;p&gt;This post walks through the architecture from the angle of "engineering decisions a tiny team needs to make to keep the lights on cheaply." Should be useful if you're building a Maps-API + Web-scraping style SaaS, or any low-budget local B2B tool.&lt;/p&gt;

&lt;h2&gt;
  
  
  Stack overview
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Front / API&lt;/strong&gt;: Next.js 15 (App Router) + TypeScript&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Deploy&lt;/strong&gt;: Vercel (Hobby plan)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Auth&lt;/strong&gt;: NextAuth.js v5 (Google OAuth + email/password)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;DB / Cache&lt;/strong&gt;: Vercel KV (Upstash Redis under the hood)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Payments&lt;/strong&gt;: Stripe Checkout + Customer Portal&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Error monitoring&lt;/strong&gt;: Sentry&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Email&lt;/strong&gt;: Resend&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Data sources&lt;/strong&gt;: Google Places API + a regulation-compliant HTML crawler&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The thesis: lean on fully-managed services everywhere possible to keep operational cost minimal.&lt;/p&gt;

&lt;h2&gt;
  
  
  Three core design decisions
&lt;/h2&gt;

&lt;h3&gt;
  
  
  1. Search → Enrich two-stage pipeline
&lt;/h3&gt;

&lt;p&gt;When a user submits "industry × region", the internal flow is:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;[Google Places Text Search] → 60 candidates
   ↓ dedupe by placeId
[Website Enrich (concurrency 16)] → fetch each business's official site
   ↓ HTML parsing
[Extract] email / Instagram / LINE / "closed?" classifier
   ↓
[Output] CSV / Google Sheets
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The trick is running stage two with &lt;strong&gt;concurrency 16&lt;/strong&gt; via &lt;code&gt;Promise.allSettled&lt;/code&gt; plus a hand-rolled &lt;code&gt;parallelMap(items, fn, concurrency)&lt;/code&gt; so per-site latency variance doesn't dominate end-to-end time.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;parallelMap&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;T&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;R&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="nx"&gt;items&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;T&lt;/span&gt;&lt;span class="p"&gt;[],&lt;/span&gt;
  &lt;span class="nx"&gt;fn&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;item&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;T&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;Promise&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;R&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;concurrency&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt;
&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="nb"&gt;Promise&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;R&lt;/span&gt;&lt;span class="p"&gt;[]&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="na"&gt;results&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;R&lt;/span&gt;&lt;span class="p"&gt;[]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Array&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;items&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;idx&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;worker&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;while &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;idx&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="nx"&gt;items&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;idx&lt;/span&gt;&lt;span class="o"&gt;++&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
      &lt;span class="nx"&gt;results&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;i&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;fn&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;items&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;i&lt;/span&gt;&lt;span class="p"&gt;]);&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nb"&gt;Promise&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;all&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;Array&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;from&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;length&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;concurrency&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="nx"&gt;worker&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;results&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;100-row list generation that takes a human 8 hours of clicking finishes in 3 minutes in the SaaS.&lt;/p&gt;

&lt;h3&gt;
  
  
  2. Cache layer abstracted so the codebase runs without KV
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kr"&gt;interface&lt;/span&gt; &lt;span class="nx"&gt;CacheStore&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;get&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;T&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;key&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="nb"&gt;Promise&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;value&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;T&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nl"&gt;storedAt&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="kd"&gt;set&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;T&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;key&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;value&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;T&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;ttlSec&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="nb"&gt;Promise&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="k"&gt;void&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nf"&gt;del&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;key&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="nb"&gt;Promise&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="k"&gt;void&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;MemoryCache&lt;/span&gt; &lt;span class="k"&gt;implements&lt;/span&gt; &lt;span class="nx"&gt;CacheStore&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="cm"&gt;/* per-instance Map */&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;KVCache&lt;/span&gt; &lt;span class="k"&gt;implements&lt;/span&gt; &lt;span class="nx"&gt;CacheStore&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="cm"&gt;/* @vercel/kv backed */&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;pickCache&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt; &lt;span class="nx"&gt;CacheStore&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;KV_REST_API_URL&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;KV_REST_API_TOKEN&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;KVCache&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;MemoryCache&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;cache&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;pickCache&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Wins:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Local dev runs with no KV env vars set&lt;/strong&gt; — falls back to Memory automatically&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Production picks up KV the moment env vars exist&lt;/strong&gt; — zero code changes to migrate&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Tests inject MemoryCache directly&lt;/strong&gt; — no Redis mocking required&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;When I added KV later, the existing cache keys started persisting in Redis with literally zero code diff.&lt;/p&gt;

&lt;h3&gt;
  
  
  3. Negative caching + per-use-case TTLs
&lt;/h3&gt;

&lt;p&gt;Google Places costs roughly $0.02 per call so cache hit rate maps directly to money. There's also a long tail of "I crawled the official site but couldn't find an email" responses, which I want to negative-cache.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;SEARCH_TTL_SEC&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;60&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;60&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;24&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;14&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;          &lt;span class="c1"&gt;// Places API results: 14d&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;ENRICH_TTL_SEC&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;60&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;60&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;24&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;30&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;          &lt;span class="c1"&gt;// Found-something: 30d&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;ENRICH_NEGATIVE_TTL_SEC&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;60&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;60&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;24&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;14&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;// Found-nothing: 14d&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;foundAnything&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="o"&gt;!!&lt;/span&gt;&lt;span class="nx"&gt;result&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;email&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="o"&gt;!!&lt;/span&gt;&lt;span class="nx"&gt;result&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;instagram&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="o"&gt;!!&lt;/span&gt;&lt;span class="nx"&gt;result&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;line&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;cache&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;set&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="nx"&gt;key&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;result&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;foundAnything&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="nx"&gt;ENRICH_TTL_SEC&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;ENRICH_NEGATIVE_TTL_SEC&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Negative cache entries use &lt;strong&gt;the same key and same value shape&lt;/strong&gt; as positive ones. The fast path is "cache hit → return immediately" with zero branching. The shorter TTL means failed lookups retry automatically 14 days later, which catches sites that have since added contact info.&lt;/p&gt;

&lt;p&gt;This pattern alone cut my Places API spend to a quarter of the original projection.&lt;/p&gt;

&lt;h2&gt;
  
  
  Programmatic SEO: 4,400 indexable pages
&lt;/h2&gt;

&lt;p&gt;Local-B2B tooling has demand fragmented across prefecture × city × industry combinations. The naive cross-product is 47 prefectures × 30 industries = 1,410 base pages, but expanding to detail-level cells creates thousands of pages where "low-population area × niche industry" combinations become thin content — which Google penalizes as scaled content abuse.&lt;/p&gt;

&lt;p&gt;So I added a &lt;strong&gt;tier × popularity gate&lt;/strong&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// city.tier:        1 (low population) - 5 (major metro center)&lt;/span&gt;
&lt;span class="c1"&gt;// industry.popularity: 1 (niche) - 3 (major)&lt;/span&gt;
&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;shouldIndexDetail&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;cityTier&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;industry&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;Industry&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="nx"&gt;boolean&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;industry&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;popularity&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;cityTier&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;industry&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;popularity&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;cityTier&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="mi"&gt;4&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;cityTier&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="mi"&gt;5&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This narrows indexable combinations from 7,321 → 4,364 and marks the rest noindex. &lt;code&gt;robots.ts&lt;/code&gt; and &lt;code&gt;sitemap.ts&lt;/code&gt; both consume this same filter, so only the strong pages get pushed to Google.&lt;/p&gt;

&lt;h2&gt;
  
  
  Daily IndexNow ping cron
&lt;/h2&gt;

&lt;p&gt;A new domain takes Google several weeks to fully crawl 4,400 pages. To speed it up I ping IndexNow daily with the full URL list via Vercel Cron:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="err"&gt;//&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;vercel.json&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"crons"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"path"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"/api/cron/onboarding"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"schedule"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"0 9 * * *"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"path"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"/api/cron/indexnow"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;   &lt;/span&gt;&lt;span class="nl"&gt;"schedule"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"15 0 * * *"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Bing / Yandex / Naver respect IndexNow with near-instant crawls. So even if Google takes its time, you have a non-Google discovery channel running.&lt;/p&gt;

&lt;h2&gt;
  
  
  Stripe webhook fire-and-forget email pattern
&lt;/h2&gt;

&lt;p&gt;Payment events trigger both a thank-you email to the customer and an alert email to the admin. I never want a transient email failure to make Stripe think the webhook failed (it'd retry, potentially double-credit accounts):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// webhooks/stripe/route.ts&lt;/span&gt;
&lt;span class="k"&gt;void&lt;/span&gt; &lt;span class="nf"&gt;notifyAdmins&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="nf"&gt;adminCheckoutNotification&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;email&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;kind&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;subscription&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;label&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;amountJpy&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt;
&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="k"&gt;catch&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;err&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;warn&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;[stripe] notif failed:&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;err&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;

&lt;span class="k"&gt;void&lt;/span&gt; &lt;span class="nf"&gt;sendMail&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;to&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;email&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;subject&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;text&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt;
  &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;catch&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;err&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;warn&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;[stripe] receipt failed:&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;err&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;

&lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;NextResponse&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;received&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Resend can be flaky for a minute and payment processing still completes. Failures land in Sentry for retroactive review.&lt;/p&gt;

&lt;p&gt;There's also a deliberate "silence on renewal" choice: I never implemented &lt;code&gt;invoice.paid&lt;/code&gt; handler, which means subscribers never get a monthly "we charged you again" email. The intent is to make the subscription fade into background — fewer "oh right, I should cancel that" moments. Lowered voluntary churn.&lt;/p&gt;

&lt;h2&gt;
  
  
  Trade-offs
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Chose&lt;/th&gt;
&lt;th&gt;Skipped&lt;/th&gt;
&lt;th&gt;Why&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Vercel KV (Upstash)&lt;/td&gt;
&lt;td&gt;PostgreSQL&lt;/td&gt;
&lt;td&gt;Stay inside Hobby budget&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Vercel Cron&lt;/td&gt;
&lt;td&gt;GitHub Actions&lt;/td&gt;
&lt;td&gt;One-file config&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Resend&lt;/td&gt;
&lt;td&gt;SendGrid&lt;/td&gt;
&lt;td&gt;3 DNS records for domain auth, clean UI&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Sentry&lt;/td&gt;
&lt;td&gt;Roll-my-own logging&lt;/td&gt;
&lt;td&gt;Min observability cost&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Stripe Customer Portal&lt;/td&gt;
&lt;td&gt;DIY cancellation flow&lt;/td&gt;
&lt;td&gt;Legally safer, no UI to maintain&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Programmatic SEO&lt;/td&gt;
&lt;td&gt;Hand-written blog&lt;/td&gt;
&lt;td&gt;Scales orders of magnitude better with quality gates&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  Actual monthly cost
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Vercel: $0 (Hobby)&lt;/li&gt;
&lt;li&gt;Vercel KV: $0 (Free plan = 500K commands/mo)&lt;/li&gt;
&lt;li&gt;Sentry: $0 (Developer plan)&lt;/li&gt;
&lt;li&gt;Resend: $0 (3,000 emails/mo)&lt;/li&gt;
&lt;li&gt;Google Places API: usage-based (currently $10–25/mo)&lt;/li&gt;
&lt;li&gt;Domain: ~$1/mo (annualized)&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Total: about $10/mo&lt;/strong&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Comfortably supports a few hundred MAU on these settings.&lt;/p&gt;

&lt;h2&gt;
  
  
  Takeaways
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Two-stage pipeline&lt;/strong&gt; for parallelism wins&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Cache abstraction&lt;/strong&gt; to keep dev/prod codepaths identical&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Negative cache + per-use-case TTL&lt;/strong&gt; can cut API cost dramatically&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;tier × popularity gate&lt;/strong&gt; keeps programmatic SEO out of spam territory&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;IndexNow cron&lt;/strong&gt; speeds up new-domain discovery&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Fire-and-forget&lt;/strong&gt; isolates external API failures from payment processing&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Silent renewals&lt;/strong&gt; reduce voluntary churn&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The actual product is at &lt;a href="https://bacotto.com/en" rel="noopener noreferrer"&gt;bacotto.com&lt;/a&gt; (free tier of 20 leads/month, no card required) — though it's Japan-focused, so the UI is in Japanese. The architecture patterns above are language-agnostic.&lt;/p&gt;

&lt;p&gt;If you're building something similar, happy to discuss specifics in the comments.&lt;/p&gt;

</description>
      <category>nextjs</category>
      <category>typescript</category>
      <category>saas</category>
      <category>webdev</category>
    </item>
  </channel>
</rss>
