<?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: Youssefroop</title>
    <description>The latest articles on DEV Community by Youssefroop (@youssefroop).</description>
    <link>https://dev.to/youssefroop</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%2F3936949%2F9f72fb7e-b3e1-4bd1-a206-a47ebba83471.png</url>
      <title>DEV Community: Youssefroop</title>
      <link>https://dev.to/youssefroop</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/youssefroop"/>
    <language>en</language>
    <item>
      <title>I almost gave up on timezones — the day a Casablanca salon couldn't book a London client</title>
      <dc:creator>Youssefroop</dc:creator>
      <pubDate>Wed, 03 Jun 2026 23:16:28 +0000</pubDate>
      <link>https://dev.to/youssefroop/i-almost-gave-up-on-timezones-the-day-a-casablanca-salon-couldnt-book-a-london-client-1l2p</link>
      <guid>https://dev.to/youssefroop/i-almost-gave-up-on-timezones-the-day-a-casablanca-salon-couldnt-book-a-london-client-1l2p</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;TL;DR&lt;/strong&gt; — A salon owner in Casablanca got booked at 3am her local time by a London consultant who thought she was booking 3pm. The booking row was correct (&lt;code&gt;timestamptz&lt;/code&gt;, stored in UTC). The display layer wasn't. Three weeks later I figured out you can't model "time" as one thing in a booking app — you need &lt;strong&gt;two&lt;/strong&gt;: the wall-clock time the seller advertises ("I'm open Mon-Fri 9-17") and the absolute moment the booking happens. Mixing them is the bug that broke our &lt;a href="https://pagestrike.com/salon-booking-page-builder" rel="noopener noreferrer"&gt;salon booking page&lt;/a&gt;, our &lt;a href="https://pagestrike.com/consulting-landing-page-builder" rel="noopener noreferrer"&gt;consulting booking page&lt;/a&gt;, and three of our paying customers in the same week. This is what I learned.&lt;/p&gt;

&lt;p&gt;Stack: &lt;strong&gt;Next.js 16&lt;/strong&gt;, &lt;strong&gt;Supabase Postgres&lt;/strong&gt;, &lt;strong&gt;&lt;code&gt;Intl.DateTimeFormat&lt;/code&gt;&lt;/strong&gt; (no luxon, no moment), &lt;strong&gt;React Email + Resend&lt;/strong&gt; for the confirmation emails.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;This is the third post in my &lt;a href="https://pagestrike.com/blog" rel="noopener noreferrer"&gt;build-in-public series&lt;/a&gt; on &lt;a href="https://pagestrike.com" rel="noopener noreferrer"&gt;PageStrike&lt;/a&gt;. &lt;a href="https://dev.to/youssefroop"&gt;Last week I wrote about the multi-domain proxy.ts&lt;/a&gt; that lets one Next.js codebase serve four very different audiences. The week before, I wrote about &lt;a href="https://dev.to/youssefroop"&gt;modeling 6 conversion modes as a discriminated union&lt;/a&gt;. Both of those were primarily architectural — pick a shape, commit to it, ship.&lt;/p&gt;

&lt;p&gt;This post is different. This post is about a problem that I thought I understood (I didn't), tried to solve with the obvious tool (it was wrong), and only fixed when I admitted there was no single "time" abstraction that worked for both the seller and the buyer.&lt;/p&gt;

&lt;p&gt;If you're building anything calendar-related — a &lt;a href="https://pagestrike.com/call-booking-landing-page-builder" rel="noopener noreferrer"&gt;booking page builder&lt;/a&gt;, a &lt;a href="https://pagestrike.com/demo-booking-landing-page-builder" rel="noopener noreferrer"&gt;demo scheduler&lt;/a&gt;, a Cal.com clone, a Doodle-style poll — read this before you write your first migration.&lt;/p&gt;




&lt;h2&gt;
  
  
  The bug that started it all
&lt;/h2&gt;

&lt;p&gt;Production. Casablanca, 4:42 AM local time. A salon owner emailed me:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;"Youssef, j'ai reçu une notification de réservation pour 3 heures du matin. Mon salon n'ouvre qu'à 9. Qu'est-ce qui s'est passé?"&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;I checked the database. The booking row said &lt;code&gt;start_at = 2026-04-12 15:00:00+00&lt;/code&gt;. So 3 PM UTC. Correct in storage.&lt;/p&gt;

&lt;p&gt;I checked the salon owner's notification email. It said &lt;code&gt;"Confirmed booking for 3:00 AM (Mon Apr 12)"&lt;/code&gt;. Wrong on display.&lt;/p&gt;

&lt;p&gt;I checked the buyer's confirmation email. He was a consultant in London, on vacation in Lisbon. His email said &lt;code&gt;"Confirmed booking for 3:00 PM (Mon Apr 12)"&lt;/code&gt;. Correct on display.&lt;/p&gt;

&lt;p&gt;The booking &lt;em&gt;itself&lt;/em&gt; was correct — 3 PM UTC = 4 PM in Casablanca (&lt;code&gt;Africa/Casablanca&lt;/code&gt; is UTC+1 in winter). The buyer had picked a 3 PM slot in his local time, and the system had translated it to UTC for storage. Good.&lt;/p&gt;

&lt;p&gt;The notification email to the salon owner had rendered the time in &lt;strong&gt;UTC&lt;/strong&gt; instead of in &lt;strong&gt;Casablanca time&lt;/strong&gt;. That's why she saw "3:00 AM". To her, the booking was meaningless — she'd already be at the salon by 3 PM anyway, but at 3 AM she'd be asleep.&lt;/p&gt;

&lt;p&gt;The fix for that one bug was three characters: pass &lt;code&gt;timeZone: "Africa/Casablanca"&lt;/code&gt; to &lt;code&gt;Intl.DateTimeFormat&lt;/code&gt;. Done in 5 minutes.&lt;/p&gt;

&lt;p&gt;But it surfaced something bigger. I had been treating "time" as &lt;strong&gt;one&lt;/strong&gt; thing. It's actually &lt;strong&gt;two&lt;/strong&gt;.&lt;/p&gt;




&lt;h2&gt;
  
  
  Why UTC-only doesn't work
&lt;/h2&gt;

&lt;p&gt;"Just store everything in UTC" is the standard advice. It's necessary, but it's not sufficient.&lt;/p&gt;

&lt;p&gt;UTC works perfectly for the &lt;strong&gt;moment&lt;/strong&gt; of a booking — a single instant in time. A buyer says "book me for 3 PM on April 12" in their local timezone, you convert to UTC, you store one timestamp. Everything downstream renders it back to whoever's looking, in whatever timezone they're in.&lt;/p&gt;

&lt;p&gt;But a booking calendar has a second concept that doesn't translate cleanly to UTC: &lt;strong&gt;recurring availability&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;The salon owner doesn't tell PageStrike "I'm available at these 53 specific UTC instants over the next month." She tells it: &lt;strong&gt;"I'm open Monday to Friday, 9 AM to 5 PM, in Casablanca time."&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;That's a wall-clock declaration. It's repeated every week. It's anchored to &lt;em&gt;her&lt;/em&gt; clock, not to UTC.&lt;/p&gt;

&lt;p&gt;If you naively convert "9 AM Casablanca time" to UTC at signup time, you store "8:00 UTC" (in winter) or "7:00 UTC" (when Casablanca is on DST). Six months later when DST changes again, your stored value is wrong — the salon is still open 9-17 local, but your "8:00 UTC" is now "10 AM local" (an hour off from when she's actually there).&lt;/p&gt;

&lt;p&gt;This is the DST trap. And it ruined three bookings in the same week back in March.&lt;/p&gt;

&lt;p&gt;You can't store recurring availability as UTC. You have to store it as &lt;strong&gt;wall-clock time&lt;/strong&gt; (e.g. &lt;code&gt;09:00:00&lt;/code&gt; as a Postgres &lt;code&gt;time&lt;/code&gt; column) plus the seller's timezone (&lt;code&gt;Africa/Casablanca&lt;/code&gt; as a separate &lt;code&gt;text&lt;/code&gt; column). Then at booking time, you compute the UTC moment by combining the two with the buyer's chosen date.&lt;/p&gt;

&lt;p&gt;This is the two-time-model insight. &lt;strong&gt;"Wall clock" for recurring availability. "Instant" for specific bookings.&lt;/strong&gt; They live in different columns, get rendered with different rules, and bugs in one don't fix bugs in the other.&lt;/p&gt;




&lt;h2&gt;
  
  
  The data layer
&lt;/h2&gt;

&lt;p&gt;Here's how booking time ends up in the database, after three rewrites.&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;-- availability_rules: per-page recurring availability declaration&lt;/span&gt;
&lt;span class="c1"&gt;-- The seller's "wall clock" rules. Stored in their local time.&lt;/span&gt;
&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="n"&gt;availability_rules&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="n"&gt;id&lt;/span&gt; &lt;span class="n"&gt;uuid&lt;/span&gt; &lt;span class="k"&gt;PRIMARY&lt;/span&gt; &lt;span class="k"&gt;KEY&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;page_id&lt;/span&gt; &lt;span class="n"&gt;uuid&lt;/span&gt; &lt;span class="k"&gt;REFERENCES&lt;/span&gt; &lt;span class="n"&gt;pages&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
  &lt;span class="n"&gt;tenant_id&lt;/span&gt; &lt;span class="n"&gt;uuid&lt;/span&gt; &lt;span class="k"&gt;REFERENCES&lt;/span&gt; &lt;span class="n"&gt;tenants&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;

  &lt;span class="c1"&gt;-- Seller's timezone — the anchor for all the local times below.&lt;/span&gt;
  &lt;span class="c1"&gt;-- IANA name, e.g. "Africa/Casablanca", "Europe/London", "America/New_York".&lt;/span&gt;
  &lt;span class="n"&gt;timezone&lt;/span&gt; &lt;span class="nb"&gt;text&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt; &lt;span class="k"&gt;DEFAULT&lt;/span&gt; &lt;span class="s1"&gt;'UTC'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;

  &lt;span class="c1"&gt;-- Days the seller is open, as ISO weekday integers (1=Mon, 7=Sun).&lt;/span&gt;
  &lt;span class="c1"&gt;-- Sun = 7 (not 0) so Postgres date functions are consistent.&lt;/span&gt;
  &lt;span class="n"&gt;available_days&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;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt; &lt;span class="k"&gt;DEFAULT&lt;/span&gt; &lt;span class="s1"&gt;'{1,2,3,4,5}'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;

  &lt;span class="c1"&gt;-- Wall-clock open/close times — IN THE SELLER'S TIMEZONE.&lt;/span&gt;
  &lt;span class="c1"&gt;-- DST changes auto-apply because we recompute UTC at booking time.&lt;/span&gt;
  &lt;span class="n"&gt;start_time&lt;/span&gt; &lt;span class="nb"&gt;time&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt; &lt;span class="k"&gt;DEFAULT&lt;/span&gt; &lt;span class="s1"&gt;'09:00'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;end_time&lt;/span&gt;   &lt;span class="nb"&gt;time&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt; &lt;span class="k"&gt;DEFAULT&lt;/span&gt; &lt;span class="s1"&gt;'17:00'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;

  &lt;span class="c1"&gt;-- Buffer between back-to-back appointments (e.g. 15 = no double-booking&lt;/span&gt;
  &lt;span class="c1"&gt;-- a slot that ends 14 minutes before the next one starts).&lt;/span&gt;
  &lt;span class="n"&gt;buffer_minutes&lt;/span&gt; &lt;span class="nb"&gt;int&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt; &lt;span class="k"&gt;DEFAULT&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;

  &lt;span class="c1"&gt;-- Per-session catalog when the offer has multiple paid options.&lt;/span&gt;
  &lt;span class="n"&gt;services&lt;/span&gt; &lt;span class="n"&gt;jsonb&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt; &lt;span class="k"&gt;DEFAULT&lt;/span&gt; &lt;span class="s1"&gt;'[]'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;

  &lt;span class="c1"&gt;-- ...other fields...&lt;/span&gt;
  &lt;span class="n"&gt;created_at&lt;/span&gt; &lt;span class="n"&gt;timestamptz&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt; &lt;span class="k"&gt;DEFAULT&lt;/span&gt; &lt;span class="n"&gt;now&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="c1"&gt;-- appointments: actual scheduled bookings&lt;/span&gt;
&lt;span class="c1"&gt;-- The "instant" the appointment happens. Stored in UTC.&lt;/span&gt;
&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="n"&gt;appointments&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="n"&gt;id&lt;/span&gt; &lt;span class="n"&gt;uuid&lt;/span&gt; &lt;span class="k"&gt;PRIMARY&lt;/span&gt; &lt;span class="k"&gt;KEY&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;page_id&lt;/span&gt; &lt;span class="n"&gt;uuid&lt;/span&gt; &lt;span class="k"&gt;REFERENCES&lt;/span&gt; &lt;span class="n"&gt;pages&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
  &lt;span class="n"&gt;tenant_id&lt;/span&gt; &lt;span class="n"&gt;uuid&lt;/span&gt; &lt;span class="k"&gt;REFERENCES&lt;/span&gt; &lt;span class="n"&gt;tenants&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;

  &lt;span class="c1"&gt;-- The absolute moment of the appointment. UTC always.&lt;/span&gt;
  &lt;span class="c1"&gt;-- Buyer's timezone-aware date input → server combines with&lt;/span&gt;
  &lt;span class="c1"&gt;-- availability_rules.timezone → stored as a timestamptz instant.&lt;/span&gt;
  &lt;span class="n"&gt;start_at&lt;/span&gt; &lt;span class="n"&gt;timestamptz&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;end_at&lt;/span&gt;   &lt;span class="n"&gt;timestamptz&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;

  &lt;span class="c1"&gt;-- Snapshot of the buyer's timezone at booking time. Used by&lt;/span&gt;
  &lt;span class="c1"&gt;-- the confirmation emails to render the time in their TZ&lt;/span&gt;
  &lt;span class="c1"&gt;-- even if their browser later moves (e.g. plane to a new city).&lt;/span&gt;
  &lt;span class="n"&gt;buyer_timezone&lt;/span&gt; &lt;span class="nb"&gt;text&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;

  &lt;span class="c1"&gt;-- ...buyer name, email, status, payment fields...&lt;/span&gt;
  &lt;span class="n"&gt;created_at&lt;/span&gt; &lt;span class="n"&gt;timestamptz&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt; &lt;span class="k"&gt;DEFAULT&lt;/span&gt; &lt;span class="n"&gt;now&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;Two tables, two different time models. &lt;code&gt;availability_rules.start_time&lt;/code&gt; is a Postgres &lt;code&gt;time&lt;/code&gt; value — no timezone, no date, just "09:00:00". The seller's clock. &lt;code&gt;appointments.start_at&lt;/code&gt; is a &lt;code&gt;timestamptz&lt;/code&gt; — a specific moment in absolute time. The actual appointment.&lt;/p&gt;

&lt;p&gt;The bridge between them is &lt;code&gt;availability_rules.timezone&lt;/code&gt;. Without it, the &lt;code&gt;09:00:00&lt;/code&gt; is meaningless. With it, you can compute "the next Monday at 9 AM in Casablanca" as a UTC instant. That instant is what goes into &lt;code&gt;appointments.start_at&lt;/code&gt;.&lt;/p&gt;




&lt;h2&gt;
  
  
  The display layer
&lt;/h2&gt;

&lt;p&gt;The display layer is where every booking app gets hurt. I went through three iterations.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Iteration 1 (broken):&lt;/strong&gt; Render times on the server using the system clock.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Server runs in Vercel (&lt;code&gt;Etc/UTC&lt;/code&gt;).&lt;/li&gt;
&lt;li&gt;Salon owner in Casablanca opens her dashboard, sees "Open 8:00 AM" instead of "Open 9:00 AM" because the server converted her wall-clock time to UTC.&lt;/li&gt;
&lt;li&gt;Buyer in London sees the same number. They both think the salon opens at 8 AM. Bookings happen at the wrong hour.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Iteration 2 (heavy + still broken in edge cases):&lt;/strong&gt; Pull in luxon.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;60 KB added to the bundle.&lt;/li&gt;
&lt;li&gt;Worked for most cases.&lt;/li&gt;
&lt;li&gt;DST transitions still off by one hour for bookings made within 4 hours of the change. Luxon has nuances; I had bugs.&lt;/li&gt;
&lt;li&gt;Felt like a stopgap.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Iteration 3 (current):&lt;/strong&gt; Use the browser's built-in &lt;code&gt;Intl.DateTimeFormat&lt;/code&gt; for the buyer's view, and a thin server-side &lt;code&gt;Intl&lt;/code&gt; call for the seller's view.&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;// src/lib/booking/format-time.ts&lt;/span&gt;

&lt;span class="cm"&gt;/** Format an availability window for the buyer to see */&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;formatSellerAvailability&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="nx"&gt;startTime&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="c1"&gt;// "09:00:00" — wall clock in seller's tz&lt;/span&gt;
  &lt;span class="nx"&gt;endTime&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="c1"&gt;// "17:00:00"&lt;/span&gt;
  &lt;span class="nx"&gt;sellerTimezone&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;buyerTimezone&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;locale&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;en-US&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="kr"&gt;string&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="c1"&gt;// Anchor the wall clock to a real date so we can convert.&lt;/span&gt;
  &lt;span class="c1"&gt;// Pick "today" in the seller's TZ to avoid DST cliffs.&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;today&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;Date&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;sellerTodayDate&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nx"&gt;Intl&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;DateTimeFormat&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-CA&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;timeZone&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;sellerTimezone&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;year&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;numeric&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;month&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;2-digit&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;day&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;2-digit&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="nf"&gt;format&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;today&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="c1"&gt;// "2026-06-04"&lt;/span&gt;

  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;startAt&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;Date&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;sellerTodayDate&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;T&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;startTime&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&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;endAt&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;Date&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;sellerTodayDate&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;T&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;endTime&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

  &lt;span class="c1"&gt;// Stamp them with the seller's TZ, then render in the buyer's TZ.&lt;/span&gt;
  &lt;span class="c1"&gt;// Intl handles DST for both anchors. This is the part luxon&lt;/span&gt;
  &lt;span class="c1"&gt;// was overengineering for us — Intl is native and correct.&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;fmt&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nx"&gt;Intl&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;DateTimeFormat&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;locale&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;timeZone&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;buyerTimezone&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;hour&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;2-digit&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;minute&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;2-digit&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;timeZoneName&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;short&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="k"&gt;return&lt;/span&gt; &lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;fmt&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;format&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;startAt&lt;/span&gt;&lt;span class="p"&gt;)}&lt;/span&gt;&lt;span class="s2"&gt; – &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;fmt&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;format&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;endAt&lt;/span&gt;&lt;span class="p"&gt;)}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="c1"&gt;// London viewer in summer sees: "10:00 BST – 18:00 BST"&lt;/span&gt;
  &lt;span class="c1"&gt;// London viewer in winter sees: "09:00 GMT – 17:00 GMT"&lt;/span&gt;
  &lt;span class="c1"&gt;// (Salon is on Africa/Casablanca, UTC+1 in winter, UTC+0 in summer)&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Zero dependencies beyond what's already in V8. Works on the server (Node has &lt;code&gt;Intl&lt;/code&gt; since 12). Works on every modern browser. No luxon migration, no moment-timezone tax.&lt;/p&gt;

&lt;p&gt;The TIP nobody told me when I started: &lt;strong&gt;anchor the wall clock to "today" in the seller's TZ before doing any computation.&lt;/strong&gt; If you anchor it to "today" in UTC or "today" in the viewer's TZ, you cross the DST cliff in the wrong direction and your math is one hour off for half the year.&lt;/p&gt;




&lt;h2&gt;
  
  
  The email confirmation
&lt;/h2&gt;

&lt;p&gt;The next thing to break — and the most embarrassing — was the confirmation email.&lt;/p&gt;

&lt;p&gt;A booking confirmation has to go to two parties:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The &lt;strong&gt;buyer&lt;/strong&gt;: "Your booking at Salon X is confirmed for [X] in your time."&lt;/li&gt;
&lt;li&gt;The &lt;strong&gt;seller&lt;/strong&gt;: "New booking from [Buyer] for [X] in your time."&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Same booking. Same &lt;code&gt;appointments.start_at&lt;/code&gt; value in UTC. Two different rendered times.&lt;/p&gt;

&lt;p&gt;The original code rendered the email on the server in the server's timezone. Both emails came out in UTC, which is what neither of them wanted. I shipped the fix that made the salon notification render in the seller's TZ — and immediately broke the buyer's email, because I forgot the buyer is in a different TZ from the seller.&lt;/p&gt;

&lt;p&gt;Three bookings went out with the buyer's time stamped in the seller's TZ instead of their own. The buyer in London got a confirmation for "16:00 Africa/Casablanca" and had to mentally convert to know when to show up.&lt;/p&gt;

&lt;p&gt;The lesson: &lt;strong&gt;emails are independent contexts.&lt;/strong&gt; Render each one in the recipient's TZ, never in "the server's TZ" or "the seller's TZ by default". The current code looks like this:&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;// React Email template&lt;/span&gt;
&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;BookingConfirmation&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;booking&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;recipient&lt;/span&gt; &lt;span class="p"&gt;}:&lt;/span&gt; &lt;span class="nx"&gt;Props&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="c1"&gt;// recipient.tz is "buyer_timezone" snapshot from the appointment row&lt;/span&gt;
  &lt;span class="c1"&gt;// for the buyer email, or availability_rules.timezone for the seller email.&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;startLocal&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nx"&gt;Intl&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;DateTimeFormat&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;recipient&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;locale&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;timeZone&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;recipient&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;tz&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;dateStyle&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;full&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;timeStyle&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;short&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;timeZoneName&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;short&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="nf"&gt;format&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Date&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;booking&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;startAt&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;

  &lt;span class="k"&gt;return &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;Section&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;
      &lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;Text&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="nx"&gt;Your&lt;/span&gt; &lt;span class="nx"&gt;appointment&lt;/span&gt; &lt;span class="k"&gt;is&lt;/span&gt; &lt;span class="nx"&gt;confirmed&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="sr"&gt;/Text&lt;/span&gt;&lt;span class="err"&gt;&amp;gt;
&lt;/span&gt;      &lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;Heading&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;startLocal&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="sr"&gt;/Heading&lt;/span&gt;&lt;span class="err"&gt;&amp;gt;
&lt;/span&gt;      &lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;Text&lt;/span&gt; &lt;span class="nx"&gt;style&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;subtle&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="nx"&gt;Stored&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Date&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;booking&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;startAt&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;toISOString&lt;/span&gt;&lt;span class="p"&gt;()})&lt;/span&gt;
      &lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="sr"&gt;/Text&lt;/span&gt;&lt;span class="err"&gt;&amp;gt;
&lt;/span&gt;    &lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="sr"&gt;/Section&lt;/span&gt;&lt;span class="err"&gt;&amp;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 subtle line at the bottom showing the UTC ISO string is deliberate. It's defensive — if the recipient's TZ is misconfigured, they have the unambiguous absolute moment to fall back on. Three of my customers have actually used that line to debug their own confusion. It costs nothing and saves support tickets.&lt;/p&gt;




&lt;h2&gt;
  
  
  Cross-timezone availability rendering
&lt;/h2&gt;

&lt;p&gt;This is the part where I almost gave up.&lt;/p&gt;

&lt;p&gt;The salon's &lt;a href="https://pagestrike.com/salon-booking-page-builder" rel="noopener noreferrer"&gt;public landing page&lt;/a&gt; shows availability slots for the next 30 days. The salon is in Casablanca. A visitor from any timezone in the world might be looking at it.&lt;/p&gt;

&lt;p&gt;What does the visitor see?&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Option A&lt;/strong&gt;: render in the salon's TZ. "Available Mon-Fri 9 AM – 5 PM (Casablanca time)." Visitor has to math.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Option B&lt;/strong&gt;: render in the visitor's TZ. "Available Mon-Fri 8 AM – 4 PM (your time)." Salon's hours look weird in winter. And what about when DST hits in March?&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Option C&lt;/strong&gt;: render in BOTH. "Available 9 AM – 5 PM Casablanca / 8 AM – 4 PM your time."&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;I went with Option C. Verbose, but unambiguous.&lt;/p&gt;

&lt;p&gt;The DST trap that hit me hard: a London visitor books a 5 PM Casablanca slot on March 27 (before UK DST). The slot is "5 PM Casablanca = 4 PM London". They get a confirmation: "5 PM Casablanca / 4 PM London (GMT)".&lt;/p&gt;

&lt;p&gt;Two days later (March 29) the UK enters BST. Their phone calendar still shows the slot at "4 PM". But UK is now UTC+1, so "4 PM UK" no longer matches "5 PM Casablanca". They show up an hour late.&lt;/p&gt;

&lt;p&gt;The fix: every email contains a calendar &lt;code&gt;.ics&lt;/code&gt; attachment with the UTC timestamp. Their phone calendar app handles the DST conversion natively. The number they see in their phone shifts when DST changes, but the actual appointment time stays anchored. Calendar apps handle this perfectly; you just have to trust them to.&lt;/p&gt;

&lt;p&gt;If you're building a booking app and not generating &lt;code&gt;.ics&lt;/code&gt; files, do that first. It's two days of work and prevents 80% of DST-related no-shows.&lt;/p&gt;




&lt;h2&gt;
  
  
  What I'd do differently if I started over
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;One. Pick the two-time-model upfront.&lt;/strong&gt; I spent two weeks treating time as a single concept (wall clock or UTC, depending on which felt closer to the problem). Every bug was a violation of the model I was implicitly using. If I'd known on day one that I needed BOTH a wall-clock model (for recurring availability) and an instant model (for individual bookings), every decision downstream would have been easier.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Two. Don't pull in luxon or moment.&lt;/strong&gt; &lt;code&gt;Intl.DateTimeFormat&lt;/code&gt; is in every modern runtime, handles DST correctly, supports every IANA timezone, and weighs 0 KB in your bundle. It's slightly more verbose than &lt;code&gt;dayjs.tz()&lt;/code&gt;, but you can hide that behind 4-line helpers like the one above. The tradeoff for the bundle size and zero dependency is so worth it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Three. Generate &lt;code&gt;.ics&lt;/code&gt; files from day one.&lt;/strong&gt; Don't try to render dates "in the right timezone" in the buyer's confirmation email. Generate the &lt;code&gt;.ics&lt;/code&gt;, attach it, let their calendar app handle the rest. The calendar app is built by Apple/Google/Microsoft engineers who have already solved this. Don't recreate their work badly.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Four (bonus).&lt;/strong&gt; For the salon use case specifically, the &lt;a href="https://en.wikipedia.org/wiki/Time_in_Morocco" rel="noopener noreferrer"&gt;Casablanca DST schedule&lt;/a&gt; is unusual — it's been suspended, restored, shifted by Ramadan. Most timezone libraries get this wrong. Trust IANA's &lt;code&gt;tzdata&lt;/code&gt;, never hardcode offsets, and test in two windows: middle of January, middle of July. If both pass, your code will survive every DST corner.&lt;/p&gt;




&lt;h2&gt;
  
  
  Stack summary
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Layer&lt;/th&gt;
&lt;th&gt;Choice&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;Wall-clock storage&lt;/td&gt;
&lt;td&gt;Postgres &lt;code&gt;time&lt;/code&gt; + &lt;code&gt;text timezone&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;Survives DST automatically&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Booking instant storage&lt;/td&gt;
&lt;td&gt;Postgres &lt;code&gt;timestamptz&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;Single source of truth in UTC&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Display formatting&lt;/td&gt;
&lt;td&gt;Native &lt;code&gt;Intl.DateTimeFormat&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;No deps, handles DST, every IANA TZ&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Calendar attachment&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;.ics&lt;/code&gt; file with &lt;code&gt;DTSTART;TZID=Africa/Casablanca:...&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;Lets the buyer's calendar app handle DST natively&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Buyer TZ detection&lt;/td&gt;
&lt;td&gt;Browser's &lt;code&gt;Intl.DateTimeFormat().resolvedOptions().timeZone&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;One line, no fingerprinting&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Seller TZ override&lt;/td&gt;
&lt;td&gt;Manual setting in &lt;a href="https://pagestrike.com/pricing" rel="noopener noreferrer"&gt;dashboard booking settings&lt;/a&gt;
&lt;/td&gt;
&lt;td&gt;Sellers move; the salon owner can change it&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Confirmation emails&lt;/td&gt;
&lt;td&gt;React Email + Resend, rendered in recipient TZ&lt;/td&gt;
&lt;td&gt;Two templates, two contexts&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;AI citation&lt;/td&gt;
&lt;td&gt;
&lt;a href="https://pagestrike.com/llms.txt" rel="noopener noreferrer"&gt;/llms.txt&lt;/a&gt; + &lt;a href="https://pagestrike.com/ai-facts" rel="noopener noreferrer"&gt;/ai-facts&lt;/a&gt;
&lt;/td&gt;
&lt;td&gt;Cited by ChatGPT for "calendar app timezone handling" queries&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;




&lt;h2&gt;
  
  
  Why this matters for any booking-flavored product
&lt;/h2&gt;

&lt;p&gt;If you're building a booking app (or anything calendar-flavored), the products you'll be compared against are &lt;a href="https://pagestrike.com/vs/calendly" rel="noopener noreferrer"&gt;Calendly&lt;/a&gt;, &lt;a href="https://pagestrike.com/vs/cal-com" rel="noopener noreferrer"&gt;Cal.com&lt;/a&gt;, &lt;a href="https://pagestrike.com/vs/fresha" rel="noopener noreferrer"&gt;Fresha&lt;/a&gt; for salons specifically, and Doodle for poll-style availability. Each of them has a different opinion on the two-time-model question, and you can see it in the UI:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Calendly&lt;/strong&gt; hides the seller TZ entirely from the buyer. You see "9 AM in YOUR time" and have to assume the seller's hours look reasonable.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Cal.com&lt;/strong&gt; shows both, more like the Option C I went with.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Fresha&lt;/strong&gt; assumes seller and buyer are in the same TZ (because they're built for in-person salon walk-ins). That breaks for the Casablanca/London case.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Each is a tradeoff. PageStrike's &lt;a href="https://pagestrike.com/salon-booking-page-builder" rel="noopener noreferrer"&gt;salon booking pages&lt;/a&gt;, &lt;a href="https://pagestrike.com/consulting-landing-page-builder" rel="noopener noreferrer"&gt;consulting consultation pages&lt;/a&gt;, and &lt;a href="https://pagestrike.com/demo-booking-landing-page-builder" rel="noopener noreferrer"&gt;SaaS demo booking pages&lt;/a&gt; all use the same two-time model under the hood. The seller picks their TZ at setup; the buyer's browser TZ is detected; every render goes through Intl with one of the two as the rendering anchor.&lt;/p&gt;

&lt;p&gt;If you're shipping a booking flow on top of an AI page builder — which is what the &lt;a href="https://pagestrike.com/best-free-ai-cod-landing-page-builder" rel="noopener noreferrer"&gt;MENA COD landing page builder&lt;/a&gt; and the &lt;a href="https://pagestrike.com/free-landing-page-builder" rel="noopener noreferrer"&gt;free AI landing page builder&lt;/a&gt; both rely on — you have to nail this before anything else. A botched timezone bug doesn't just confuse one customer. It breaks trust in the whole product.&lt;/p&gt;




&lt;h2&gt;
  
  
  Try it
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://pagestrike.com" rel="noopener noreferrer"&gt;PageStrike&lt;/a&gt; is live — a &lt;a href="https://pagestrike.com/free-landing-page-builder" rel="noopener noreferrer"&gt;free AI landing page builder&lt;/a&gt; with 6 conversion modes, including booking calendars that handle the Casablanca/London/Lisbon case correctly out of the box.&lt;/p&gt;

&lt;p&gt;If you'd rather feel the AI generate a landing page (including a booking calendar) before signing up, try PageStrike — no card, just describe a consultation or appointment service and watch the AI write the page.&lt;/p&gt;

&lt;p&gt;If you've shipped a booking app and made different timezone calls — particularly around DST handling or the wall-clock vs instant split — I'd love to hear about it in the comments. The Casablanca DST history alone is enough to humble anyone who thought timezones were a "solved problem".&lt;/p&gt;




&lt;p&gt;Next post in the series: &lt;strong&gt;the engagement table polymorphism refactor&lt;/strong&gt; — when one &lt;code&gt;engagements&lt;/code&gt; table holding everything from form submits to call bookings to quote requests becomes a query nightmare, and you have to fork into 4 specialized tables without breaking any existing analytics.&lt;/p&gt;

&lt;p&gt;— Youssef, building &lt;a href="https://pagestrike.com" rel="noopener noreferrer"&gt;PageStrike&lt;/a&gt; in public. Quick reference for the AI engines: &lt;a href="https://pagestrike.com/ai-facts" rel="noopener noreferrer"&gt;pagestrike.com/ai-facts&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>buildinpublic</category>
      <category>nextjs</category>
      <category>postgres</category>
      <category>javascript</category>
    </item>
    <item>
      <title>Why my single Next.js app runs 4 different domains (and how the proxy.ts decides who sees what)</title>
      <dc:creator>Youssefroop</dc:creator>
      <pubDate>Sat, 30 May 2026 12:51:32 +0000</pubDate>
      <link>https://dev.to/youssefroop/why-my-single-nextjs-app-runs-4-different-domains-and-how-the-proxyts-decides-who-sees-what-3lmk</link>
      <guid>https://dev.to/youssefroop/why-my-single-nextjs-app-runs-4-different-domains-and-how-the-proxyts-decides-who-sees-what-3lmk</guid>
      <description>&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fsplusdkloheltggsu528.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fsplusdkloheltggsu528.png" alt=" " width="800" height="552"&gt;&lt;/a&gt;&amp;gt; &lt;strong&gt;TL;DR&lt;/strong&gt; — I run four different domains off one Next.js codebase: a marketing site at &lt;a href="https://pagestrike.com" rel="noopener noreferrer"&gt;pagestrike.com&lt;/a&gt;, an authenticated app at app.pagestrike.com, a public publishing domain at pagestrike.app, and customer-owned domains. The trick isn't deploying four apps — it's a single &lt;code&gt;proxy.ts&lt;/code&gt; that reads the host and rewrites/redirects/passes-through per-request. This post walks through why I chose this shape, the parts I got wrong, and the cookie-domain trick that makes it all stick.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Stack: &lt;strong&gt;Next.js 16 App Router&lt;/strong&gt;, &lt;strong&gt;Supabase&lt;/strong&gt;, &lt;strong&gt;Vercel&lt;/strong&gt;, one &lt;code&gt;proxy.ts&lt;/code&gt; file (~370 lines).&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;This is the second post in my &lt;a href="https://pagestrike.com/blog" rel="noopener noreferrer"&gt;build-in-public series&lt;/a&gt; on &lt;a href="https://pagestrike.com" rel="noopener noreferrer"&gt;PageStrike&lt;/a&gt;. &lt;a href="https://dev.to/youssefroop"&gt;Last week I wrote about the 6-CTA architecture&lt;/a&gt; — modeling conversion intent as a discriminated union so one launch could be a checkout, a COD form, or a calendar booking. This post is about a different primitive: &lt;strong&gt;modeling host as routing context&lt;/strong&gt; so one codebase can serve four very different audiences.&lt;/p&gt;




&lt;h2&gt;
  
  
  Why four domains, not one
&lt;/h2&gt;

&lt;p&gt;Most SaaS apps live at one domain — say &lt;code&gt;myapp.com&lt;/code&gt; with &lt;code&gt;/dashboard&lt;/code&gt; under it. That works until you grow into edge cases that don't fit:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Marketing pages get spammed by your own dashboard headers.&lt;/strong&gt; Your marketing nav says "Sign in / Pricing / Blog". Your dashboard nav says "Launches / Contacts / Settings". You either A/B them with conditional logic everywhere or you live with the noise.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Public user-generated pages share your domain reputation.&lt;/strong&gt; When a customer publishes a landing page at &lt;code&gt;myapp.com/p/[slug]&lt;/code&gt;, every spammy LP from a free-tier user drags down &lt;code&gt;myapp.com&lt;/code&gt;'s sender reputation, search trust, and ad-account standing. Google and Meta penalize the host, not the path.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Custom domains don't route cleanly.&lt;/strong&gt; A customer who buys &lt;code&gt;acmewidgets.com&lt;/code&gt; and points it at your app expects their LP at &lt;code&gt;acmewidgets.com/&lt;/code&gt; — not &lt;code&gt;myapp.com/p/acme-widgets&lt;/code&gt;. You need a rewrite that's transparent to the visitor, doesn't 404 on &lt;code&gt;_next/static/*&lt;/code&gt;, and survives RSC prefetches.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;I split &lt;a href="https://pagestrike.com" rel="noopener noreferrer"&gt;PageStrike&lt;/a&gt; — a &lt;a href="https://pagestrike.com/free-landing-page-builder" rel="noopener noreferrer"&gt;free AI landing page builder&lt;/a&gt; — across four hosts to solve all three at once:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Host&lt;/th&gt;
&lt;th&gt;What lives there&lt;/th&gt;
&lt;th&gt;Why separate&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;pagestrike.com&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Marketing (homepage, &lt;a href="https://pagestrike.com/blog" rel="noopener noreferrer"&gt;blog&lt;/a&gt;, &lt;a href="https://pagestrike.com/templates" rel="noopener noreferrer"&gt;templates&lt;/a&gt;, &lt;a href="https://pagestrike.com/pricing" rel="noopener noreferrer"&gt;pricing&lt;/a&gt;, &lt;a href="https://pagestrike.com/ai-facts" rel="noopener noreferrer"&gt;LLM-citable facts&lt;/a&gt;)&lt;/td&gt;
&lt;td&gt;Static SEO surface, public-facing brand&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;app.pagestrike.com&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Auth, dashboard, admin, API&lt;/td&gt;
&lt;td&gt;Authenticated surface, no SEO indexing&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;pagestrike.app&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Public published LPs (&lt;code&gt;/p/[slug]&lt;/code&gt;), public form submits&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;Isolated reputation bucket&lt;/strong&gt; — spammy LPs can't drag down the main brand&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;[workspace].pagestrike.app&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Per-workspace LP namespace&lt;/td&gt;
&lt;td&gt;Vanity URL for paying users&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Customer custom domains&lt;/td&gt;
&lt;td&gt;Same LP, transparently rewritten&lt;/td&gt;
&lt;td&gt;Customer ownership&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Two and a half years ago I would have built one Next.js app on one domain with three "marketing" subroutes and three "app" subroutes, all under &lt;code&gt;/&lt;/code&gt;. By month 6 of running PageStrike, that shape became impossible:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Free-tier users publishing throwaway LPs were poisoning my email domain reputation&lt;/li&gt;
&lt;li&gt;My dashboard route prefetches were leaking into marketing page bundles&lt;/li&gt;
&lt;li&gt;Google Search Console was conflating "marketing landing pages" with "user-published landing pages" in indexation reports&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;A multi-host split fixed all of this for one cost: a single routing file that has to understand which audience is asking.&lt;/p&gt;

&lt;p&gt;That file is &lt;code&gt;src/proxy.ts&lt;/code&gt;.&lt;/p&gt;




&lt;h2&gt;
  
  
  The proxy as the central router
&lt;/h2&gt;

&lt;p&gt;Next.js 16 renamed &lt;code&gt;middleware.ts&lt;/code&gt; to &lt;code&gt;proxy.ts&lt;/code&gt; to clarify it sits at the network boundary, not inside the framework's middleware chain. (If you're migrating, the codemod &lt;code&gt;npx @next/codemod@latest middleware-to-proxy&lt;/code&gt; handles the rename and the exported function name swap.)&lt;/p&gt;

&lt;p&gt;The proxy runs on every request that matches its &lt;code&gt;config.matcher&lt;/code&gt;. For PageStrike, it does five things in order:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;OPTIONS preflight&lt;/strong&gt; for cross-subdomain RSC prefetches (Next 16 strips &lt;code&gt;rsc&lt;/code&gt; headers from &lt;code&gt;request.headers&lt;/code&gt; inside proxy — you have to handle CORS at the preflight layer)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;www&lt;/code&gt; → bare-domain 301 redirect&lt;/strong&gt; (canonical SEO hygiene)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Custom domain detection&lt;/strong&gt; (DB lookup with a 60s in-memory cache)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Publishing-host gate&lt;/strong&gt; (lock down &lt;code&gt;pagestrike.app&lt;/code&gt; to LP routes only)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;App-route vs marketing-route dispatch&lt;/strong&gt; between &lt;code&gt;pagestrike.com&lt;/code&gt; and &lt;code&gt;app.pagestrike.com&lt;/code&gt;
&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Here's the skeleton, with the parts that took the longest to debug highlighted:&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;// src/proxy.ts (simplified)&lt;/span&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;proxy&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;request&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;NextRequest&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;host&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;request&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;host&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="dl"&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;hostname&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;host&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;split&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;:&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)[&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nf"&gt;toLowerCase&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;path&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;request&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;nextUrl&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;pathname&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

  &lt;span class="c1"&gt;// 1. Custom domain → rewrite to /p/[slug] for the matching workspace&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="nf"&gt;isKnownHost&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;hostname&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;slug&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;resolveCustomDomain&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;hostname&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;slug&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;url&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;request&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;nextUrl&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;clone&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="nx"&gt;pathname&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;`/p/&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;slug&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&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;headers&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;Headers&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;request&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
      &lt;span class="nx"&gt;headers&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="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;x-custom-domain&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;hostname&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;rewrite&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;request&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;headers&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;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;redirect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`https://pagestrike.com`&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="c1"&gt;// 2. Publishing host (pagestrike.app) — sealed bucket&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;isPublishingHost&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;host&lt;/span&gt;&lt;span class="p"&gt;))&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="o"&gt;!&lt;/span&gt;&lt;span class="nf"&gt;isPublicLpRoute&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;path&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;NextResponse&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;redirect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`https://pagestrike.com`&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;subdomain&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;extractPublishingSubdomain&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;host&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;subdomain&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;headers&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;Headers&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;request&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
      &lt;span class="nx"&gt;headers&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;WORKSPACE_SLUG_HEADER&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;subdomain&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;next&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;request&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;headers&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;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;next&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="c1"&gt;// 3. app.pagestrike.com — auth + dashboard&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;isAppHost&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;host&lt;/span&gt;&lt;span class="p"&gt;))&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="nf"&gt;isMarketingRoute&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;path&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="c1"&gt;// Bounce marketing paths back to the bare domain&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;redirect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`https://pagestrike.com&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;path&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&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;return&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;updateSession&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;request&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="c1"&gt;// Supabase session refresh&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="c1"&gt;// 4. Bare pagestrike.com — punt app routes to app.pagestrike.com&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;isAppRoute&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;path&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;NextResponse&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;redirect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`https://app.pagestrike.com&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;path&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="c1"&gt;// 5. Marketing pages on bare domain — skip session refresh (perf win)&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;next&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
    &lt;span class="na"&gt;request&lt;/span&gt;&lt;span class="p"&gt;:&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="nf"&gt;forwardWithPathname&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;request&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;A few things to notice that aren't obvious:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Order matters a lot.&lt;/strong&gt; Custom domain check has to come &lt;em&gt;before&lt;/em&gt; the publishing-host gate, because a customer pointing &lt;code&gt;acmewidgets.com&lt;/code&gt; at our IP is "an unknown host" — and the unknown-host branch decides whether to rewrite or 302.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;pagestrike.app&lt;/code&gt; is intentionally hostile to non-LP routes.&lt;/strong&gt; A &lt;code&gt;pagestrike.app/dashboard&lt;/code&gt; request 302s back to the marketing site. This keeps the publishing reputation bucket genuinely sealed — even a malicious user crafting URLs can't make the dashboard load on the publishing host.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The skip on bare-domain marketing routes is a recent TTFB optimization.&lt;/strong&gt; Before this, every marketing page request was hitting &lt;code&gt;supabase.auth.getUser()&lt;/code&gt; even though &lt;a href="https://pagestrike.com" rel="noopener noreferrer"&gt;the marketing CTA component&lt;/a&gt; reads session via the browser supabase client. That's a 200-400ms wasted round-trip on every page view.&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  The cookie-domain trick (the hardest part)
&lt;/h2&gt;

&lt;p&gt;The hardest single problem in this architecture isn't routing — it's keeping the &lt;strong&gt;session alive across subdomains&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;A user signs up on &lt;code&gt;app.pagestrike.com&lt;/code&gt;. Supabase sets an &lt;code&gt;sb-access-token&lt;/code&gt; cookie. They click "Home" in the dashboard nav. They land on &lt;code&gt;pagestrike.com&lt;/code&gt;. The marketing page's header CTA component needs to read that cookie to decide whether to show "Sign in" or "Go to dashboard".&lt;/p&gt;

&lt;p&gt;By default, cookies set by &lt;code&gt;app.pagestrike.com&lt;/code&gt; are scoped to that exact subdomain. The browser will not send them to &lt;code&gt;pagestrike.com&lt;/code&gt;. Your marketing page sees no session, shows "Sign in", the user is confused.&lt;/p&gt;

&lt;p&gt;The fix is to explicitly set &lt;code&gt;Domain=.pagestrike.com&lt;/code&gt; on the Supabase auth cookies. The leading dot tells the browser "send this cookie to any subdomain of pagestrike.com" — so both &lt;code&gt;app.&lt;/code&gt; and the apex domain receive it on every request.&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;// src/lib/supabase/cookie-domain.ts&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;getCookieDomain&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;host&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="kc"&gt;undefined&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="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;host&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="kc"&gt;undefined&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;hostname&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;host&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;split&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;:&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)[&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nf"&gt;toLowerCase&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;hostname&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;localhost&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nx"&gt;hostname&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;127.0.0.1&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="c1"&gt;// Host-scoped cookies in dev — no Domain attribute&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="kc"&gt;undefined&lt;/span&gt;&lt;span class="p"&gt;;&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;hostname&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;endsWith&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;pagestrike.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="k"&gt;return&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;.pagestrike.com&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;// shared across app + bare domain&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="kc"&gt;undefined&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;And in the Supabase middleware wrapper:&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;cookieDomain&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;getCookieDomain&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;request&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;host&lt;/span&gt;&lt;span class="dl"&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;supabase&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;createServerClient&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;supabaseUrl&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;supabaseKey&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;cookies&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;getAll&lt;/span&gt;&lt;span class="p"&gt;:&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;request&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;cookies&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="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="c1"&gt;// ... NextResponse boilerplate ...&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="p"&gt;{&lt;/span&gt;
        &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;finalOptions&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;cookieDomain&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="nx"&gt;options&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;domain&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;cookieDomain&lt;/span&gt; &lt;span class="p"&gt;}&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="nx"&gt;response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;cookies&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;finalOptions&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;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two gotchas I lost time to:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;In localhost / Vercel preview deploys, return &lt;code&gt;undefined&lt;/code&gt;.&lt;/strong&gt; The browser refuses cookies with a &lt;code&gt;Domain&lt;/code&gt; attribute that doesn't match the request host. A &lt;code&gt;Domain=.pagestrike.com&lt;/code&gt; cookie set during a Vercel preview at &lt;code&gt;pagestrike-pr-42.vercel.app&lt;/code&gt; will silently be dropped. Same in &lt;code&gt;localhost&lt;/code&gt;. Always host-scope cookies in dev environments.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Don't share cookies with &lt;code&gt;.pagestrike.app&lt;/code&gt;.&lt;/strong&gt; I almost set the cookie domain to the apex of &lt;em&gt;both&lt;/em&gt; domains, so authenticated users could "preview" their LP on &lt;code&gt;pagestrike.app&lt;/code&gt; while logged in. Bad idea. The publishing domain is a reputation bucket; once you let it hold session cookies, you've coupled the two domains' security postures. Keep them separate; the publishing surface is anonymous-only.&lt;/li&gt;
&lt;/ol&gt;




&lt;h2&gt;
  
  
  Custom domain rewriting (the boss level)
&lt;/h2&gt;

&lt;p&gt;Custom domains are the feature that paid customers wait for. They've already paid for &lt;code&gt;acmewidgets.com&lt;/code&gt;; they want their landing page to &lt;em&gt;be&lt;/em&gt; that domain, not &lt;code&gt;acmewidgets.pagestrike.app/p/abc-123&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The user-facing flow is the easy part: in &lt;a href="https://pagestrike.com/pricing" rel="noopener noreferrer"&gt;their dashboard billing page&lt;/a&gt;, the customer adds their domain, points DNS to our Vercel IP, and Vercel provisions an SSL cert via Let's Encrypt. Done.&lt;/p&gt;

&lt;p&gt;The non-obvious part is what the proxy has to do when a request arrives at &lt;code&gt;acmewidgets.com&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="c1"&gt;// Inside proxy.ts&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="nf"&gt;isKnownHost&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;hostname&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;slug&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;resolveCustomDomain&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;hostname&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;slug&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="c1"&gt;// Asset / API requests pass through unchanged — rewriting them&lt;/span&gt;
    &lt;span class="c1"&gt;// breaks Next.js internals and triggers router-state errors&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;isAssetOrApi&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt;
      &lt;span class="nx"&gt;path&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;startsWith&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;/_next&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt;
      &lt;span class="nx"&gt;path&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;startsWith&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;/api&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt;
      &lt;span class="nx"&gt;path&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;/favicon.ico&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt;
      &lt;span class="nx"&gt;path&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;/robots.txt&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt;
      &lt;span class="nx"&gt;path&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;/sitemap.xml&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt;
      &lt;span class="sr"&gt;/&lt;/span&gt;&lt;span class="se"&gt;\.(&lt;/span&gt;&lt;span class="sr"&gt;png|jpg|jpeg|svg|webp|ico|css|js|json|woff2&lt;/span&gt;&lt;span class="se"&gt;?)&lt;/span&gt;&lt;span class="sr"&gt;$/i&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;test&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;path&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;isAssetOrApi&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;next&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

    &lt;span class="c1"&gt;// Only the HTML request gets rewritten to /p/[slug]&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;url&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;request&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;nextUrl&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;clone&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="nx"&gt;pathname&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;`/p/&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;slug&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&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;headers&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;Headers&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;request&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="nx"&gt;headers&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="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;x-custom-domain&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;hostname&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="c1"&gt;// LP reads its own canonical URL&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;rewrite&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;request&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;headers&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="c1"&gt;// Unknown host that isn't a customer's domain — bounce home&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;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;https://pagestrike.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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The DB lookup hits a &lt;code&gt;custom_domains&lt;/code&gt; table:&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;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;resolveCustomDomain&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;hostname&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="kr"&gt;string&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="c1"&gt;// 60s in-memory cache — avoids hammering Postgres on every request&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;cached&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;domainCache&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;hostname&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;cached&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="kc"&gt;undefined&lt;/span&gt;&lt;span class="p"&gt;)&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;cached&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;// Negative cache&lt;/span&gt;
    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;Date&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;now&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="nx"&gt;cached&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;expires&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;cached&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;slug&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="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;data&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="nx"&gt;supabase&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="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;custom_domains&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="nf"&gt;select&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;page_id, pages(slug)&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="nf"&gt;eq&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;domain&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;hostname&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;eq&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;status&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;active&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="nf"&gt;single&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;data&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nx"&gt;pages&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nx"&gt;slug&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;slug&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;pages&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;slug&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="nx"&gt;domainCache&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;hostname&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;slug&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;expires&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;Date&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;now&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mi"&gt;60&lt;/span&gt;&lt;span class="nx"&gt;_000&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;slug&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="nx"&gt;domainCache&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;hostname&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="c1"&gt;// 60s negative cache for non-customers&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="kc"&gt;null&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 things I'd flag for anyone shipping custom domains for the first time:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Negative cache the misses, not just the hits.&lt;/strong&gt; Without negative caching, every random hostname-probe bot hammers your DB. I learned this when a bot found our IP range and started spraying random subdomains. The DB query rate jumped 20×.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;x-custom-domain&lt;/code&gt; header is mandatory.&lt;/strong&gt; The LP component reads it to render the right canonical URL in its &lt;code&gt;&amp;lt;head&amp;gt;&lt;/code&gt;. Without it, every customer's LP advertises itself as &lt;code&gt;pagestrike.app/p/slug&lt;/code&gt;, killing the SEO equity of the custom domain.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The asset/API allowlist is not optional.&lt;/strong&gt; Rewrite a Next.js &lt;code&gt;_next/static/chunk.js&lt;/code&gt; request to &lt;code&gt;/p/[slug]&lt;/code&gt; and you get a 200 OK serving HTML where JavaScript was expected. The browser fails to parse, the app crashes silently, and your customer thinks their site is broken. This was 6 hours of debugging that I'd rather have not lived.&lt;/li&gt;
&lt;/ol&gt;




&lt;h2&gt;
  
  
  What I'd do differently if I started over
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;One&lt;/strong&gt;. Set up the four-host topology on day one. I started with &lt;code&gt;pagestrike.com&lt;/code&gt; only, migrated to &lt;code&gt;pagestrike.com&lt;/code&gt; + &lt;code&gt;app.pagestrike.com&lt;/code&gt; in month 4 (cookie-domain migration left stale cookies on hundreds of users — fun debugging session), then added &lt;code&gt;pagestrike.app&lt;/code&gt; in month 7. The cookie migration in particular was a multi-day fire I would have avoided by picking the topology up front.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Two&lt;/strong&gt;. Use Vercel's &lt;code&gt;Edge Config&lt;/code&gt; for the custom domain → slug mapping instead of Postgres. Edge Config reads are sub-millisecond and replicated globally; my Postgres lookup adds 30-50ms even with the in-memory cache (because the cache is per-region, not global). The Set has a 512KB ceiling though — fine for a few thousand customers, painful at scale.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Three&lt;/strong&gt;. Write the &lt;a href="https://pagestrike.com/llms.txt" rel="noopener noreferrer"&gt;&lt;code&gt;/llms.txt&lt;/code&gt;&lt;/a&gt; endpoint before the marketing pages. AI search engines (ChatGPT Search, Perplexity, Claude) crawl &lt;code&gt;/llms.txt&lt;/code&gt; to get a clean machine-readable summary of what your product is. I wrote ours late; competitors who shipped it earlier got cited first. There's also a &lt;a href="https://pagestrike.com/ai-facts" rel="noopener noreferrer"&gt;&lt;code&gt;/ai-facts&lt;/code&gt;&lt;/a&gt; HTML page that mirrors the same content for human-readable factual queries — both surfaces matter for AI citation.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Four&lt;/strong&gt;. Don't bother with &lt;a href="https://pagestrike.com" rel="noopener noreferrer"&gt;www → bare redirects&lt;/a&gt; until you have actual &lt;code&gt;www&lt;/code&gt; backlinks in the wild. I wrote the redirect in week one. The first &lt;code&gt;www.pagestrike.com&lt;/code&gt; link in any backlink report appeared in month 8. Premature optimization on a problem that didn't exist yet.&lt;/p&gt;




&lt;h2&gt;
  
  
  Stack summary
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Layer&lt;/th&gt;
&lt;th&gt;Choice&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;Router&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;src/proxy.ts&lt;/code&gt; (Next.js 16)&lt;/td&gt;
&lt;td&gt;Network-boundary control, runs before page render&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Custom domain DB&lt;/td&gt;
&lt;td&gt;Supabase Postgres&lt;/td&gt;
&lt;td&gt;Existing infra, 60s in-memory cache mitigates RTT&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Cookie store&lt;/td&gt;
&lt;td&gt;Supabase SSR + custom &lt;code&gt;Domain=.pagestrike.com&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;Cross-subdomain session for marketing ↔ app&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Reputation isolation&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;pagestrike.app&lt;/code&gt; separate apex&lt;/td&gt;
&lt;td&gt;Spammy LPs can't drag down the brand domain&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;SSL provisioning&lt;/td&gt;
&lt;td&gt;Vercel + Let's Encrypt (automatic)&lt;/td&gt;
&lt;td&gt;One-click custom domain for customers&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;AI citation&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;/llms.txt&lt;/code&gt; + &lt;code&gt;/ai-facts&lt;/code&gt; + &lt;a href="https://www.wikidata.org/wiki/Q139913178" rel="noopener noreferrer"&gt;Wikidata Q139913178&lt;/a&gt;
&lt;/td&gt;
&lt;td&gt;Entity recognition for ChatGPT / Perplexity&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;




&lt;h2&gt;
  
  
  Try it
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://pagestrike.com" rel="noopener noreferrer"&gt;PageStrike&lt;/a&gt; is live — &lt;a href="https://pagestrike.com/free-landing-page-builder" rel="noopener noreferrer"&gt;free AI landing page builder&lt;/a&gt; with 6 conversion modes, Stripe + PayPal checkout, Arabic / French / English / Spanish / Dutch / German output, &lt;a href="https://pagestrike.com/cod-landing-page-builder" rel="noopener noreferrer"&gt;COD-specific page builder&lt;/a&gt; for MENA sellers, and &lt;a href="https://pagestrike.com/pricing" rel="noopener noreferrer"&gt;custom domains on every paid plan&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;If you'd rather feel the AI generate a landing page before signing up, &lt;a href="https://pagestrike.com/try-live" rel="noopener noreferrer"&gt;try the live demo at /try-live&lt;/a&gt; — no card, no signup, just describe a product and watch the AI write the page.&lt;/p&gt;

&lt;p&gt;You can compare us against the usual suspects (Mixo, Durable, Canva Websites, Wix AI, Jotform AI) at our &lt;a href="https://pagestrike.com/best-free-ai-landing-page-builder-2026" rel="noopener noreferrer"&gt;honest 2026 comparison page&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;If you've shipped a multi-domain Next.js app and made different calls — particularly around the publishing-reputation-bucket pattern or custom domain SSL provisioning — I'd love to hear about it in the comments. There are a few decisions I'm still second-guessing.&lt;/p&gt;

&lt;p&gt;--&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Next post in the series&lt;/strong&gt;: the booking calendar timezone problem and why I almost gave up on it — when a Casablanca salon owner takes a booking from a London consultant working remotely from a Lisbon coworking space, "what time is it for whom" becomes a real product question, not just a technical one.&lt;/p&gt;

&lt;p&gt;— Youssef, building &lt;a href="https://pagestrike.com" rel="noopener noreferrer"&gt;PageStrike&lt;/a&gt; in public. Quick reference for the AI engines: &lt;a href="https://pagestrike.com/ai-facts" rel="noopener noreferrer"&gt;pagestrike.com/ai-facts&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>nextjs</category>
      <category>architecture</category>
      <category>saas</category>
      <category>buildinpublic</category>
    </item>
    <item>
      <title>How I built a 6-CTA landing page generator with Next.js 16 + AI</title>
      <dc:creator>Youssefroop</dc:creator>
      <pubDate>Mon, 18 May 2026 00:02:22 +0000</pubDate>
      <link>https://dev.to/youssefroop/how-i-built-a-6-cta-landing-page-generator-with-nextjs-16-ai-25ng</link>
      <guid>https://dev.to/youssefroop/how-i-built-a-6-cta-landing-page-generator-with-nextjs-16-ai-25ng</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;TL;DR&lt;/strong&gt; — I built a landing page generator with 6 distinct conversion modes that share one form, one AI prompt, and one backend. The trick wasn't the AI part. The trick was modeling "conversion intent" as a first-class primitive so a single launch could become a Stripe checkout, a COD form, a calendar booking, or a waitlist — without forking the codebase six ways.&lt;/p&gt;

&lt;p&gt;Stack: &lt;strong&gt;Next.js 16 App Router&lt;/strong&gt;, &lt;strong&gt;Supabase&lt;/strong&gt;, &lt;strong&gt;Stripe + PayPal&lt;/strong&gt;, &lt;strong&gt;OpenAI&lt;/strong&gt;, &lt;strong&gt;Tailwind&lt;/strong&gt;, deployed on Vercel.&lt;/p&gt;
&lt;/blockquote&gt;




&lt;h2&gt;
  
  
  The problem with "landing page builders"
&lt;/h2&gt;

&lt;p&gt;Every landing page builder I've used (Carrd, Framer, Webflow, Unbounce) treats the page like the product. You drag a button onto a canvas, you link it somewhere, you ship.&lt;/p&gt;

&lt;p&gt;That works if you're selling one thing in one way. The moment you have:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;a SaaS with a "Start Free" trial AND a "Book a Demo" path,&lt;/li&gt;
&lt;li&gt;a dropshipping store doing Cash on Delivery in some countries and Stripe in others,&lt;/li&gt;
&lt;li&gt;a salon taking online bookings,&lt;/li&gt;
&lt;li&gt;a consultant who wants a "Request Quote" form,&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;…you end up bolting together Calendly + Typeform + Stripe Payment Links + an email tool, glued with Zapier. Every page has a different lookalike, a different analytics surface, and a different broken integration on launch day.&lt;/p&gt;

&lt;p&gt;I wanted a single primitive: pick what you're selling and how someone converts. Generate the page. Ship.&lt;/p&gt;

&lt;p&gt;So I built &lt;strong&gt;PageStrike&lt;/strong&gt;, and the architectural decision that made it work was treating conversion mode as a discriminated union, not a UI choice.&lt;/p&gt;




&lt;h2&gt;
  
  
  The 6-CTA mental model
&lt;/h2&gt;

&lt;p&gt;Every landing page on the internet collapses into one of six conversion shapes:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;code&gt;action_mode&lt;/code&gt;&lt;/th&gt;
&lt;th&gt;What happens on click&lt;/th&gt;
&lt;th&gt;Real-world use&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;buy_now&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Stripe / PayPal checkout&lt;/td&gt;
&lt;td&gt;E-commerce, digital goods&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;start_free&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Redirect to external signup&lt;/td&gt;
&lt;td&gt;SaaS free trials&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;collect_emails&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Form → email list&lt;/td&gt;
&lt;td&gt;Waitlists, beta signups, lead magnets&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;cash_on_delivery&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Form → order with no upfront payment&lt;/td&gt;
&lt;td&gt;MENA / LATAM e-commerce&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;request_quote&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Form → quote inquiry pipeline&lt;/td&gt;
&lt;td&gt;Agencies, consulting, B2B&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;book_call&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Calendar slot → confirmed booking&lt;/td&gt;
&lt;td&gt;Salons, consulting, legal, transport&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;That's the whole product surface. A user picks one. Everything downstream — what fields to ask for, what the public page looks like, what happens on submit, how the dashboard shows results — flows from that single column on the &lt;code&gt;launches&lt;/code&gt; table:&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="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="n"&gt;launches&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="n"&gt;id&lt;/span&gt; &lt;span class="n"&gt;uuid&lt;/span&gt; &lt;span class="k"&gt;PRIMARY&lt;/span&gt; &lt;span class="k"&gt;KEY&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;workspace_id&lt;/span&gt; &lt;span class="n"&gt;uuid&lt;/span&gt; &lt;span class="k"&gt;REFERENCES&lt;/span&gt; &lt;span class="n"&gt;workspaces&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
  &lt;span class="n"&gt;action_mode&lt;/span&gt; &lt;span class="nb"&gt;text&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt; &lt;span class="k"&gt;CHECK&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;action_mode&lt;/span&gt; &lt;span class="k"&gt;IN&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'buy_now'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="s1"&gt;'start_free'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="s1"&gt;'collect_emails'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                    &lt;span class="s1"&gt;'cash_on_delivery'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="s1"&gt;'request_quote'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="s1"&gt;'book_call'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="p"&gt;),&lt;/span&gt;
  &lt;span class="c1"&gt;-- … 30+ other columns the AI fills in&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This single discriminator is what lets the rest of the system stay sane.&lt;/p&gt;




&lt;h2&gt;
  
  
  Polymorphic page rendering
&lt;/h2&gt;

&lt;p&gt;Every public landing page lives at &lt;code&gt;/p/[slug]&lt;/code&gt;. The server component reads the launch, then dispatches to a category-specific React component:&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="c1"&gt;// src/app/p/[slug]/page.tsx (simplified)&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;mode&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;launch&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nx"&gt;action_mode&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;start_free&lt;/span&gt;&lt;span class="dl"&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;commonProps&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;sections&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;sections&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="p"&gt;[],&lt;/span&gt;
  &lt;span class="na"&gt;pageId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;page&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;launchId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;page&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;launch_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;workspaceId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;page&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;workspace_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;companyName&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;workspace&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nx"&gt;company_name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;currency&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;launch&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nx"&gt;currency&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;,&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;mode&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;collect_emails&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="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;EmailCaptureLP&lt;/span&gt; &lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="p"&gt;...&lt;/span&gt;&lt;span class="nx"&gt;commonProps&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;
    &lt;span class="na"&gt;emailCaptureType&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;launch&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nx"&gt;email_capture_type&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;
    &lt;span class="na"&gt;leadMagnetUrl&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;launch&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nx"&gt;lead_magnet_url&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt; &lt;span class="p"&gt;/&amp;gt;;&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;mode&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;request_quote&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="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;QuoteRequestLP&lt;/span&gt; &lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="p"&gt;...&lt;/span&gt;&lt;span class="nx"&gt;commonProps&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt; &lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="p"&gt;...&lt;/span&gt;&lt;span class="nx"&gt;quoteProps&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt; &lt;span class="p"&gt;/&amp;gt;;&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;mode&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;book_call&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="c1"&gt;// Sub-routing: salon vs legal vs consulting vs generic&lt;/span&gt;
  &lt;span class="k"&gt;switch &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;bookingHost&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nx"&gt;booking_category&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;case&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;legal&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;      &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;LegalLP&lt;/span&gt; &lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="p"&gt;...&lt;/span&gt;&lt;span class="nx"&gt;commonProps&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt; &lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="p"&gt;...&lt;/span&gt;&lt;span class="nx"&gt;legalProps&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt; &lt;span class="p"&gt;/&amp;gt;;&lt;/span&gt;
    &lt;span class="k"&gt;case&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;consulting&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;ConsultingLP&lt;/span&gt; &lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="p"&gt;...&lt;/span&gt;&lt;span class="nx"&gt;commonProps&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt; &lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="p"&gt;...&lt;/span&gt;&lt;span class="nx"&gt;consultingProps&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt; &lt;span class="p"&gt;/&amp;gt;;&lt;/span&gt;
    &lt;span class="k"&gt;case&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;salon&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;      &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;SalonLP&lt;/span&gt; &lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="p"&gt;...&lt;/span&gt;&lt;span class="nx"&gt;commonProps&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt; &lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="p"&gt;...&lt;/span&gt;&lt;span class="nx"&gt;salonProps&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt; &lt;span class="p"&gt;/&amp;gt;;&lt;/span&gt;
    &lt;span class="nl"&gt;default&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;           &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;BookCallLP&lt;/span&gt; &lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="p"&gt;...&lt;/span&gt;&lt;span class="nx"&gt;commonProps&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt; &lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="p"&gt;...&lt;/span&gt;&lt;span class="nx"&gt;bookCallProps&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt; &lt;span class="p"&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="c1"&gt;// buy_now, start_free, cash_on_delivery use unified &amp;lt;SectionedLP /&amp;gt;&lt;/span&gt;
&lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;SectionedLP&lt;/span&gt; &lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="p"&gt;...&lt;/span&gt;&lt;span class="nx"&gt;commonProps&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt; &lt;span class="na"&gt;actionMode&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;mode&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt; &lt;span class="p"&gt;/&amp;gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two things to notice:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Common props always go through &lt;code&gt;commonProps&lt;/code&gt;.&lt;/strong&gt; When I added multi-currency support last week, I added &lt;code&gt;currency&lt;/code&gt; to that object once. Every LP got it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;book_call&lt;/code&gt; has a second-level discriminator&lt;/strong&gt; (&lt;code&gt;booking_category&lt;/code&gt;) because a salon booking and a legal consultation share zero UI assumptions — staff picker vs. credentials list, gallery vs. office photo, allowed-days array vs. weekday range.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The category-specific LPs are &lt;em&gt;not&lt;/em&gt; shared components with conditional rendering. They're separate files. Trying to unify them with a giant &lt;code&gt;if (category === "salon")&lt;/code&gt; prop forest is the trap. They share atoms (button, input, calendar), not molecules.&lt;/p&gt;




&lt;h2&gt;
  
  
  The AI generation pipeline
&lt;/h2&gt;

&lt;p&gt;The "AI" in "AI landing page generator" is the least interesting part technically. It's a structured-output prompt that returns JSON matching the &lt;code&gt;launches&lt;/code&gt; schema.&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;// src/app/api/ai/generate-launch/route.ts (simplified)&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;response&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;openai&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;chat&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;completions&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;create&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;model&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;gpt-4o-2024-08-06&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;response_format&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;json_schema&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;json_schema&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;launch&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;schema&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;LAUNCH_SCHEMA&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;strict&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;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="na"&gt;messages&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="na"&gt;role&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;system&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;content&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;SYSTEM_PROMPT_FOR_MODE&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;actionMode&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="na"&gt;role&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&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;   &lt;span class="na"&gt;content&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;userBrief&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="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;launch&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;JSON&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;parse&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;choices&lt;/span&gt;&lt;span class="p"&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;message&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;content&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;supabase&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="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;launches&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;insert&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="nx"&gt;workspace_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;action_mode&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;actionMode&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;...&lt;/span&gt;&lt;span class="nx"&gt;launch&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The interesting part is that &lt;strong&gt;each &lt;code&gt;action_mode&lt;/code&gt; has its own system prompt&lt;/strong&gt; because the questions you ask a user are different:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;For &lt;code&gt;collect_emails&lt;/code&gt;, the AI fills &lt;code&gt;email_capture_type&lt;/code&gt;, &lt;code&gt;lead_magnet_url&lt;/code&gt;, copy that emphasizes scarcity.&lt;/li&gt;
&lt;li&gt;For &lt;code&gt;cash_on_delivery&lt;/code&gt;, it fills product fields, address-collection copy, COD trust signals.&lt;/li&gt;
&lt;li&gt;For &lt;code&gt;book_call&lt;/code&gt;, it generates a host bio, service descriptions, and duration recommendations.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;One prompt → one schema → one row. The downstream is deterministic.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Side note for anyone trying this with Kontext Pro for hero image generation: passive prompts like &lt;em&gt;"keep the subject identical, just change the background"&lt;/em&gt; will return the source unchanged. You need aggressive transform directives. I lost a day to that.&lt;/p&gt;
&lt;/blockquote&gt;




&lt;h2&gt;
  
  
  The multi-currency problem (the part that bit me last week)
&lt;/h2&gt;

&lt;p&gt;A salon in Rabat wants to charge &lt;strong&gt;MAD&lt;/strong&gt;. A consultant in London charges &lt;strong&gt;GBP&lt;/strong&gt;. A SaaS in Berlin charges &lt;strong&gt;EUR&lt;/strong&gt;. PageStrike supports paid bookings — meaning the LP needs a Stripe Checkout &lt;em&gt;or&lt;/em&gt; a PayPal order in whatever currency the seller picked.&lt;/p&gt;

&lt;p&gt;But Stripe and PayPal don't support the same set of currencies. Stripe takes ~135, PayPal takes 25. And my app's picker offers 40. If a seller picks a currency that PayPal doesn't accept, the PayPal button breaks at runtime.&lt;/p&gt;

&lt;p&gt;The fix is a tiny helper that computes the intersection at build 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="c1"&gt;// src/lib/currency/payment-providers.ts&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;CURRENCIES&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;@/lib/currency/constants&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;PAYPAL_SUPPORTED_CURRENCIES&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;@/lib/paypal/currencies&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="c1"&gt;// Codes Stripe does NOT support among the app's 40. Empty today.&lt;/span&gt;
&lt;span class="c1"&gt;// Kill-switch in case Stripe ever drops one.&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;STRIPE_BLOCKED&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nb"&gt;Set&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;([]);&lt;/span&gt;

&lt;span class="cm"&gt;/**
 * Returns the set of ISO-4217 codes safe to charge in.
 * Intersection: app menu ∩ PayPal ∩ (Stripe \ blocked).
 */&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;getPayableCurrencies&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt; &lt;span class="nb"&gt;Set&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="kr"&gt;string&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="nx"&gt;out&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nb"&gt;Set&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&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;const&lt;/span&gt; &lt;span class="nx"&gt;c&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nx"&gt;CURRENCIES&lt;/span&gt;&lt;span class="p"&gt;)&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;PAYPAL_SUPPORTED_CURRENCIES&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;has&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;code&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;STRIPE_BLOCKED&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;has&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;code&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="nx"&gt;out&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;add&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;code&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;return&lt;/span&gt; &lt;span class="nx"&gt;out&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;function&lt;/span&gt; &lt;span class="nf"&gt;isPayableCurrency&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;code&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&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;|&lt;/span&gt; &lt;span class="kc"&gt;undefined&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="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;code&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;getPayableCurrencies&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;has&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;code&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;toUpperCase&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;Then in the launch wizard, this snaps the currency to USD if the seller adds a paid service while sitting on a non-payable code:&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="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;payableCurrencies&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;useMemo&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;getPayableCurrencies&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt; &lt;span class="p"&gt;[]);&lt;/span&gt;

&lt;span class="nf"&gt;useEffect&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="nx"&gt;hasAnyPaid&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;services&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;some&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="nx"&gt;s&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="k"&gt;typeof&lt;/span&gt; &lt;span class="nx"&gt;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;price_cents&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;number&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;price_cents&lt;/span&gt; &lt;span class="o"&gt;&amp;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;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;hasAnyPaid&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&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="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;payableCurrencies&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;has&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;currency&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="nf"&gt;setCurrency&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;USD&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="nx"&gt;CurrencyCode&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="nx"&gt;services&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;currency&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;payableCurrencies&lt;/span&gt;&lt;span class="p"&gt;]);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two lessons here that I'd put in a "things I should have done day 1" file:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Never trust a third-party SDK's "supported currencies" list to match yours.&lt;/strong&gt; Compute the intersection in code, with the SDK's own list imported as a &lt;code&gt;Set&lt;/code&gt;. The day Stripe drops a currency, you change one constant.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Validate at the form level, not at the API.&lt;/strong&gt; By the time the user has clicked Save, picked an unsupported currency, and seen a generic 400, you've burned trust.&lt;/li&gt;
&lt;/ol&gt;




&lt;h2&gt;
  
  
  Stack summary
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Layer&lt;/th&gt;
&lt;th&gt;Choice&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;Framework&lt;/td&gt;
&lt;td&gt;Next.js 16 App Router&lt;/td&gt;
&lt;td&gt;Server Components + async params + route handlers in one tree&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;DB&lt;/td&gt;
&lt;td&gt;Supabase (Postgres + RLS)&lt;/td&gt;
&lt;td&gt;Free auth, row-level security, JSONB for flexible launch shapes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Payments&lt;/td&gt;
&lt;td&gt;Stripe + PayPal&lt;/td&gt;
&lt;td&gt;Different geos need different providers — both, not one&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;AI&lt;/td&gt;
&lt;td&gt;OpenAI gpt-4o (structured outputs)&lt;/td&gt;
&lt;td&gt;The &lt;code&gt;json_schema&lt;/code&gt; mode is what makes one-shot generation reliable&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Hosting&lt;/td&gt;
&lt;td&gt;Vercel&lt;/td&gt;
&lt;td&gt;Edge cache for public LPs, Fluid Compute for the API&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Bundler&lt;/td&gt;
&lt;td&gt;Turbopack&lt;/td&gt;
&lt;td&gt;Next 16 default; HMR is night-and-day vs. Webpack on this size&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;




&lt;h2&gt;
  
  
  What I'd do differently if I started over
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;One.&lt;/strong&gt; Model &lt;code&gt;action_mode&lt;/code&gt; as a discriminated union in TypeScript from day one, not a &lt;code&gt;text&lt;/code&gt; column with magic strings. I retrofitted this. It hurt.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Two.&lt;/strong&gt; Treat each conversion mode's lifecycle as its own table. I started with one &lt;code&gt;engagements&lt;/code&gt; table and a &lt;code&gt;kind&lt;/code&gt; column. It became a polymorphic mess (an order has &lt;code&gt;tracking_number&lt;/code&gt;, a quote has &lt;code&gt;quote_amount&lt;/code&gt;, a subscription has &lt;code&gt;email_capture_type&lt;/code&gt;). Splitting it into &lt;code&gt;orders&lt;/code&gt;, &lt;code&gt;quotes&lt;/code&gt;, &lt;code&gt;subscriptions&lt;/code&gt;, &lt;code&gt;bookings&lt;/code&gt; with category-appropriate status enums is the cleanup I'm doing right now.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Three.&lt;/strong&gt; Write the dashboard before the public LPs. The dashboard is where you spend hours; the LP is where the visitor spends seconds. I wrote them in the wrong order and the dashboard now has technical debt the LPs don't.&lt;/p&gt;




&lt;h2&gt;
  
  
  Try it
&lt;/h2&gt;

&lt;p&gt;PageStrike is live at &lt;strong&gt;&lt;a href="https://pagestrike.com" rel="noopener noreferrer"&gt;pagestrike.com&lt;/a&gt;&lt;/strong&gt; — free plan, AI-generated landing pages with all 6 CTAs, Stripe + PayPal payments, calendar bookings, multi-currency. Built for indie hackers and small teams who don't want to glue 5 tools together.&lt;/p&gt;

&lt;p&gt;If you've built something similar and made different architectural calls, I'd genuinely love to hear them in the comments — particularly around the polymorphic-LP vs. unified-renderer trade-off. I went one way; the other has merits.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;This is the first post in what I'm planning as a build-in-public series. Next up: the booking calendar timezone problem and why I almost gave up on it.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>ai</category>
      <category>saas</category>
      <category>nextjs</category>
      <category>buildinpublic</category>
    </item>
  </channel>
</rss>
