<?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: ohyeah</title>
    <description>The latest articles on DEV Community by ohyeah (@ohyeah_d04cd4c2cd46a1ad2c).</description>
    <link>https://dev.to/ohyeah_d04cd4c2cd46a1ad2c</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%2F3905431%2F7aaf17d8-ee8b-4d77-98ed-602442d3f592.jpg</url>
      <title>DEV Community: ohyeah</title>
      <link>https://dev.to/ohyeah_d04cd4c2cd46a1ad2c</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/ohyeah_d04cd4c2cd46a1ad2c"/>
    <language>en</language>
    <item>
      <title>I built my own UptimeRobot in a weekend with Next.js 16 + Vercel Cron</title>
      <dc:creator>ohyeah</dc:creator>
      <pubDate>Thu, 30 Apr 2026 05:50:13 +0000</pubDate>
      <link>https://dev.to/ohyeah_d04cd4c2cd46a1ad2c/i-built-my-own-uptimerobot-in-a-weekend-with-nextjs-16-vercel-cron-34d9</link>
      <guid>https://dev.to/ohyeah_d04cd4c2cd46a1ad2c/i-built-my-own-uptimerobot-in-a-weekend-with-nextjs-16-vercel-cron-34d9</guid>
      <description>&lt;p&gt;I've been paying UptimeRobot for years. It works. The free tier is generous. I have no real beef with them.&lt;/p&gt;

&lt;p&gt;But every time I added a 6th monitor, the upgrade modal appeared. Every time I logged in to check a site, the dashboard nudged me toward Pro. Every time I wanted a public status page on my own domain, that was a paid feature too.&lt;/p&gt;

&lt;p&gt;Eventually I asked the question every indie dev asks at some point: how hard could this actually be?&lt;/p&gt;

&lt;p&gt;It turned out: a weekend to MVP, two weeks to ship to paying customers. Here's the architecture, the parts that surprised me, and the bugs that cost me an afternoon each.&lt;/p&gt;

&lt;h2&gt;
  
  
  The whole product, on one page
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Probe a list of URLs every minute.&lt;/strong&gt; HEAD or GET, optional body keyword check.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Detect "down" reliably&lt;/strong&gt; — don't email you because of one flaky packet.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Email when status flips.&lt;/strong&gt; Don't email every minute the site stays down.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Render a public status page&lt;/strong&gt; at a custom slug.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Bill it.&lt;/strong&gt; $9/month for 25 monitors, free up to 5.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That's the spec. Anything else I considered building, I asked: "would my own indie projects need this?" The answer for incident management, on-call rotations, request tracing, RUM, and Slack threading was: no. So they didn't get built.&lt;/p&gt;

&lt;h2&gt;
  
  
  The stack
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Next.js 16&lt;/strong&gt; (App Router) on Vercel&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Supabase&lt;/strong&gt; for Postgres + Auth (Tokyo region — more on this below)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Vercel Cron&lt;/strong&gt; runs a single endpoint every minute&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Resend&lt;/strong&gt; for alert emails&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Stripe&lt;/strong&gt; Checkout + webhooks for billing&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That's it. No queue, no Redis, no separate worker fleet. The whole backend is one cron endpoint and a handful of Server Actions.&lt;/p&gt;

&lt;h2&gt;
  
  
  The 1-minute heartbeat
&lt;/h2&gt;

&lt;p&gt;Vercel Cron sends a GET to &lt;code&gt;/api/cron/check&lt;/code&gt; every minute. A single endpoint handles every monitor on the platform — no per-monitor crons, no fan-out queue.&lt;/p&gt;

&lt;p&gt;The flow:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;cron tick
  → claim_due_monitors (Postgres function, atomic SELECT FOR UPDATE)
    → process up to 200 monitors in parallel batches of 25
      → fetch each URL with AbortController timeout
        → upsert check result + flip status if needed
          → enqueue alert if status transitioned
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The Postgres function is the load-bearing piece. It locks rows that are due for a check, bumps their &lt;code&gt;next_check_at&lt;/code&gt;, and returns them in one round-trip. Two cron workers will never claim the same monitor in the same tick, because Postgres handles the contention for me.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="c1"&gt;-- simplified&lt;/span&gt;
&lt;span class="k"&gt;create&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;claim_due_monitors&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;p_limit&lt;/span&gt; &lt;span class="nb"&gt;int&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;returns&lt;/span&gt; &lt;span class="k"&gt;setof&lt;/span&gt; &lt;span class="n"&gt;monitors&lt;/span&gt;
&lt;span class="k"&gt;language&lt;/span&gt; &lt;span class="n"&gt;plpgsql&lt;/span&gt;
&lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="err"&gt;$$&lt;/span&gt;
&lt;span class="k"&gt;begin&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;query&lt;/span&gt;
    &lt;span class="k"&gt;update&lt;/span&gt; &lt;span class="n"&gt;monitors&lt;/span&gt;
    &lt;span class="k"&gt;set&lt;/span&gt; &lt;span class="n"&gt;next_check_at&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;now&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;interval_seconds&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;interval&lt;/span&gt; &lt;span class="s1"&gt;'1 second'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;where&lt;/span&gt; &lt;span class="n"&gt;id&lt;/span&gt; &lt;span class="k"&gt;in&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
      &lt;span class="k"&gt;select&lt;/span&gt; &lt;span class="n"&gt;id&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="n"&gt;monitors&lt;/span&gt;
      &lt;span class="k"&gt;where&lt;/span&gt; &lt;span class="n"&gt;next_check_at&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;=&lt;/span&gt; &lt;span class="n"&gt;now&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="k"&gt;and&lt;/span&gt; &lt;span class="n"&gt;active&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;true&lt;/span&gt;
      &lt;span class="k"&gt;order&lt;/span&gt; &lt;span class="k"&gt;by&lt;/span&gt; &lt;span class="n"&gt;next_check_at&lt;/span&gt;
      &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="k"&gt;update&lt;/span&gt; &lt;span class="n"&gt;skip&lt;/span&gt; &lt;span class="n"&gt;locked&lt;/span&gt;
      &lt;span class="k"&gt;limit&lt;/span&gt; &lt;span class="n"&gt;p_limit&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;returning&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;end&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="err"&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;for update skip locked&lt;/code&gt; is the magic. It lets a second cron worker (which won't happen here, but you want it to be safe) skip rows that are already being processed instead of waiting for a lock.&lt;/p&gt;

&lt;h2&gt;
  
  
  Probing 25 URLs concurrently in one function
&lt;/h2&gt;

&lt;p&gt;Each tick can hit dozens of URLs. The cron route batches them with &lt;code&gt;Promise.allSettled&lt;/code&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="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;CONCURRENCY&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;25&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;for &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;i&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="nx"&gt;i&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="nx"&gt;monitors&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="nx"&gt;i&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="nx"&gt;CONCURRENCY&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;slice&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;monitors&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;slice&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="nx"&gt;i&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;CONCURRENCY&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;results&lt;/span&gt; &lt;span class="o"&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;allSettled&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="nx"&gt;slice&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;map&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;m&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;processMonitor&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;admin&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;m&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
  &lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="c1"&gt;// ...tally results, log errors&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The probe itself is just &lt;code&gt;fetch&lt;/code&gt; with three things you must get right:&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;controller&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;AbortController&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;timeout&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;setTimeout&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;controller&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;abort&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt; &lt;span class="nx"&gt;monitor&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;timeout_ms&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="k"&gt;try&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;res&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;fetch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;monitor&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;url&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;method&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;monitor&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;keyword&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;GET&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;monitor&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;method&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;redirect&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;follow&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;signal&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;controller&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;signal&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;headers&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;user-agent&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;SitePulseBot/1.0 (+https://sitepulse.satosushi.co)&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;cache-control&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;no-cache&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="na"&gt;cache&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;no-store&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="c1"&gt;// never let Next cache a probe&lt;/span&gt;
  &lt;span class="p"&gt;});&lt;/span&gt;

  &lt;span class="c1"&gt;// ALWAYS drain the body, even if you don't need it.&lt;/span&gt;
  &lt;span class="c1"&gt;// Otherwise the socket stays open and the next probe pays connect cost.&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;monitor&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;keyword&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;try&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;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;arrayBuffer&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="p"&gt;}&lt;/span&gt;

  &lt;span class="c1"&gt;// ...record result&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;finally&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nf"&gt;clearTimeout&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;timeout&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;Three subtle things in there:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;cache: "no-store"&lt;/code&gt;&lt;/strong&gt; — Next.js will happily cache &lt;code&gt;fetch&lt;/code&gt; responses in production. You don't want a cached HTTP probe.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Drain the body&lt;/strong&gt; — if you don't read the response body, the underlying connection sits in limbo. Across hundreds of probes per minute, this matters.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;AbortController&lt;/code&gt; for timeouts&lt;/strong&gt; — &lt;code&gt;fetch&lt;/code&gt; has no built-in timeout. The default is "wait forever." Don't.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  The 1-second latency I didn't notice for two days
&lt;/h2&gt;

&lt;p&gt;I deployed the first version and a page load felt sluggish. Not broken — just sluggish. Maybe 800ms-1.2s for the dashboard to render.&lt;/p&gt;

&lt;p&gt;Vercel Functions default to &lt;code&gt;iad1&lt;/code&gt; (Washington DC). My Supabase project is in Tokyo. Every Server Component that hit the database was making a US-east → Tokyo → US-east round trip per query. With 3-4 queries per page render, that's a second of pure network sitting between the user and the page.&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;"regions"&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="s2"&gt;"hnd1"&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;One line. Pinning functions to Tokyo (&lt;code&gt;hnd1&lt;/code&gt;) drops Server Component render time to under 100ms. The lesson generalises: &lt;strong&gt;always colocate compute with your primary data store&lt;/strong&gt;, especially for Server Components, where every render is a synchronous database conversation.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Server Component cookie crash
&lt;/h2&gt;

&lt;p&gt;Next.js 16's App Router gives Server Components access to cookies. Supabase's &lt;code&gt;createServerClient&lt;/code&gt; wants a &lt;code&gt;setAll&lt;/code&gt; callback so it can refresh tokens.&lt;/p&gt;

&lt;p&gt;But Server Components are read-only — you can't set cookies during a render in production. If a token refresh happens during a Server Component pass, &lt;code&gt;setAll&lt;/code&gt; throws, and the entire page returns a 500 with that lovely React digest:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Server Components render
 → Supabase tries to refresh expired token
 → setAll attempts to write cookies
 → ERR_HTTP_HEADERS_SENT-style error
 → 500 with digest 972974443
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Fix is one try/catch:&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="p"&gt;{&lt;/span&gt;
  &lt;span class="nl"&gt;cookies&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nf"&gt;getAll&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="nx"&gt;cookieStore&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getAll&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
    &lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="nf"&gt;setAll&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;cookiesToSet&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nx"&gt;cookiesToSet&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;forEach&lt;/span&gt;&lt;span class="p"&gt;(({&lt;/span&gt; &lt;span class="nx"&gt;name&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;options&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;cookieStore&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;name&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;options&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;catch&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="c1"&gt;// Server Component context — token will be refreshed on next request.&lt;/span&gt;
        &lt;span class="c1"&gt;// Safe to swallow.&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="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The Supabase docs hint at this but the existing examples I copied didn't have the try/catch. If your Supabase + Next.js 16 app sometimes 500s on logged-in users after a token expiry, this is probably why.&lt;/p&gt;

&lt;h2&gt;
  
  
  The trailing whitespace bug that ate three hours
&lt;/h2&gt;

&lt;p&gt;I copied my &lt;code&gt;STRIPE_WEBHOOK_SECRET&lt;/code&gt; from the Stripe CLI output into Vercel's env var UI. Webhooks 401'd in production. Local was fine.&lt;/p&gt;

&lt;p&gt;The Stripe webhook secret has a trailing newline if you copy from a terminal. Vercel stores it verbatim — including the newline. The HTTP header &lt;code&gt;Stripe-Signature&lt;/code&gt; then doesn't match anything, signature verification fails, and you get a 400 in your logs with no obvious cause.&lt;/p&gt;

&lt;p&gt;The fix is to never trust your clipboard:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="nt"&gt;-n&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$STRIPE_WEBHOOK_SECRET&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; | &lt;span class="nb"&gt;tr&lt;/span&gt; &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="s1"&gt;'\n\r\t '&lt;/span&gt; | vercel &lt;span class="nb"&gt;env &lt;/span&gt;add STRIPE_WEBHOOK_SECRET production
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Same gotcha applies to any header-borne secret: API tokens, basic auth, JWT signing keys. If signature verification fails in prod but works locally, check whitespace before checking anything else.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I deliberately didn't build
&lt;/h2&gt;

&lt;p&gt;The list of things people expect from a "real" uptime monitor that I left out:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Logs / RUM / transaction monitoring.&lt;/strong&gt; That's what Sentry and Logflare are for.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Multi-region probing.&lt;/strong&gt; I check from one region. If Cloudflare is down in São Paulo and your site is up in São Paulo, you and I will both find out at the same time.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;On-call schedules / rotations.&lt;/strong&gt; Indie devs are a one-person rotation. If I'm asleep, the alert waits.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Slack / Discord / PagerDuty / OpsGenie.&lt;/strong&gt; Email + SMS covers the people I'm building for.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;5-second checks.&lt;/strong&gt; 1-minute is enough for indie projects. Sub-minute is genuinely expensive to do reliably.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Cutting features wasn't a sacrifice — it was the product. The competitors I respect (UptimeRobot, BetterStack, Pingdom) all do most of what I left out, and that's exactly why their pricing pages have four columns and a "contact sales" button.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I learned about flat pricing
&lt;/h2&gt;

&lt;p&gt;The most-discussed part of this product hasn't been the technical stack — it's been the price.&lt;/p&gt;

&lt;p&gt;$9/month for 25 monitors. No per-seat. No per-region. No per-channel. Free up to 5 monitors.&lt;/p&gt;

&lt;p&gt;The reasoning: when I'm picking a tool for a side project, I don't have time to evaluate three pricing tiers and figure out which one I'd grow into. I want one number. Flat pricing forces the product to do less, which forces me to make better tradeoffs about what to build.&lt;/p&gt;

&lt;p&gt;It also means the product can never become "enterprise." That's fine. There are already excellent enterprise uptime monitors. There aren't enough good ones for indie devs.&lt;/p&gt;

&lt;h2&gt;
  
  
  The link
&lt;/h2&gt;

&lt;p&gt;The product is live at &lt;strong&gt;&lt;a href="https://sitepulse.satosushi.co" rel="noopener noreferrer"&gt;sitepulse.satosushi.co&lt;/a&gt;&lt;/strong&gt; — 5 monitors free, $9/mo for 25, no card to start. If you've been on UptimeRobot and want to see how it stacks up, I wrote a &lt;a href="https://sitepulse.satosushi.co/vs/uptimerobot" rel="noopener noreferrer"&gt;side-by-side comparison&lt;/a&gt; too.&lt;/p&gt;

&lt;p&gt;Not open source — that's the business — but happy to answer architecture questions in the comments. The cron-claim-and-fan-out pattern in particular has been more reliable than any queue I've shipped, and I think it generalises to a lot of "do this thing every N seconds for N users" problems where you'd otherwise reach for SQS.&lt;/p&gt;

</description>
      <category>nextjs</category>
      <category>webdev</category>
      <category>vercel</category>
      <category>indiehackers</category>
    </item>
  </channel>
</rss>
