<?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: rokcso</title>
    <description>The latest articles on DEV Community by rokcso (@rokcso).</description>
    <link>https://dev.to/rokcso</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%2F3844396%2F6d387839-fdda-4867-8b28-6d9ff75af7ac.png</url>
      <title>DEV Community: rokcso</title>
      <link>https://dev.to/rokcso</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/rokcso"/>
    <language>en</language>
    <item>
      <title>How I Ran a Live Production Upgrade in 24 Minutes Without Taking the Site Down</title>
      <dc:creator>rokcso</dc:creator>
      <pubDate>Wed, 15 Apr 2026 06:40:10 +0000</pubDate>
      <link>https://dev.to/rokcso/how-i-ran-a-live-production-upgrade-in-24-minutes-without-taking-the-site-down-4hcn</link>
      <guid>https://dev.to/rokcso/how-i-ran-a-live-production-upgrade-in-24-minutes-without-taking-the-site-down-4hcn</guid>
      <description>&lt;p&gt;I do not like touching production unless I have to.&lt;/p&gt;

&lt;p&gt;Feature work is fun. Production upgrades are not. Feature work gives you screenshots. Production upgrades give you backups, validation queries, freeze switches, and a very direct answer to the question: do you actually trust your system?&lt;/p&gt;

&lt;p&gt;I recently had to do one of those upgrades for &lt;a href="https://shipstry.com" rel="noopener noreferrer"&gt;Shipstry&lt;/a&gt;, a product launch platform I run on Cloudflare Workers.&lt;/p&gt;

&lt;p&gt;The maintenance window took about 24 minutes.&lt;/p&gt;

&lt;p&gt;During that window, I:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;froze writes with a centralized maintenance guard&lt;/li&gt;
&lt;li&gt;backed up production D1&lt;/li&gt;
&lt;li&gt;migrated comment data into a cleaner split model&lt;/li&gt;
&lt;li&gt;moved payments onto a new internal purchase domain&lt;/li&gt;
&lt;li&gt;backfilled historical orders conservatively&lt;/li&gt;
&lt;li&gt;verified that old entitlements and historical paid amounts still matched before reopening writes&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Public pages stayed up the whole time. That was the main requirement from the start.&lt;/p&gt;

&lt;h2&gt;
  
  
  TL;DR
&lt;/h2&gt;

&lt;p&gt;The release worked because I treated it like an operational change, not a schema change.&lt;/p&gt;

&lt;p&gt;The migration script was only one part of it. The real release was:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;one switch to freeze writes&lt;/li&gt;
&lt;li&gt;a production backup taken before any destructive step&lt;/li&gt;
&lt;li&gt;baseline counts recorded in advance&lt;/li&gt;
&lt;li&gt;validation gates after every risky move&lt;/li&gt;
&lt;li&gt;no pressure to reopen writes early&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;One SQL migration did fail on the first run because Cloudflare D1 remote execution rejected an explicit transaction wrapper in the file. That did not turn into an incident because the safety rails were already in place.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why I changed the model
&lt;/h2&gt;

&lt;p&gt;This upgrade was triggered by two areas that had started to outgrow the old schema.&lt;/p&gt;

&lt;p&gt;The first was comments.&lt;/p&gt;

&lt;p&gt;Product comments and blog comments had drifted enough that pretending they were the same thing was no longer buying simplicity. It was just deferring cleanup.&lt;/p&gt;

&lt;p&gt;The second was payments.&lt;/p&gt;

&lt;p&gt;The old &lt;code&gt;order&lt;/code&gt; model was good enough when it handled a narrower set of paid actions. It became less convincing once the platform started supporting multiple payment-backed behaviors:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;backlinks access&lt;/li&gt;
&lt;li&gt;paid submissions&lt;/li&gt;
&lt;li&gt;product upgrades&lt;/li&gt;
&lt;li&gt;upgrade pricing that respects what a maker already paid before&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;At that point I needed the database to answer business questions directly, instead of forcing new features to keep carrying legacy assumptions around.&lt;/p&gt;

&lt;p&gt;The core separation I wanted was:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;the payment itself&lt;/li&gt;
&lt;li&gt;what the user bought&lt;/li&gt;
&lt;li&gt;what that purchase unlocks&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That sounds abstract until you hit questions like:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Does an old backlinks purchase still grant access?&lt;/li&gt;
&lt;li&gt;If someone already paid for an earlier tier, does that amount still count toward the next upgrade?&lt;/li&gt;
&lt;li&gt;Can I evolve the payment system without special-casing old data everywhere?&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Once those questions become common, "we will clean it up later" stops being practical.&lt;/p&gt;

&lt;h2&gt;
  
  
  The rule that mattered most
&lt;/h2&gt;

&lt;p&gt;I did not want a full outage.&lt;/p&gt;

&lt;p&gt;I also did not want live writes continuing while I changed production data underneath them.&lt;/p&gt;

&lt;p&gt;So the operating rule was simple:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;reads stay up, writes freeze first&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;That only works if you have a real maintenance switch. Not a plan to be careful. Not a checklist item. A switch that the app actually respects.&lt;/p&gt;

&lt;p&gt;Before release day, I added a centralized &lt;code&gt;MAINTENANCE_WRITE_FREEZE&lt;/code&gt; guard and wired it through the places that can mutate D1:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;checkout creation&lt;/li&gt;
&lt;li&gt;Stripe webhook mutations&lt;/li&gt;
&lt;li&gt;comments and likes&lt;/li&gt;
&lt;li&gt;votes&lt;/li&gt;
&lt;li&gt;profile updates&lt;/li&gt;
&lt;li&gt;admin write actions&lt;/li&gt;
&lt;li&gt;draft save, submit, and delete flows&lt;/li&gt;
&lt;li&gt;notification endpoints that mutate state&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;I was not optimizing for elegance. I was optimizing for control.&lt;/p&gt;

&lt;p&gt;If I have to do this again, I want one switch that really means one switch.&lt;/p&gt;

&lt;h2&gt;
  
  
  The boring work that makes production survivable
&lt;/h2&gt;

&lt;p&gt;Before touching a migration, I exported production D1.&lt;/p&gt;

&lt;p&gt;That should feel boring. If backups feel exciting, you are already too late.&lt;/p&gt;

&lt;p&gt;I also recorded baselines before the destructive parts:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;legacy comment count&lt;/li&gt;
&lt;li&gt;legacy comment-like count&lt;/li&gt;
&lt;li&gt;top-level vs reply comment counts&lt;/li&gt;
&lt;li&gt;soft-deleted comments&lt;/li&gt;
&lt;li&gt;legacy order count&lt;/li&gt;
&lt;li&gt;paid backlinks count&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Those numbers became release gates later.&lt;/p&gt;

&lt;p&gt;Not "it seems fine."&lt;/p&gt;

&lt;p&gt;Not "the page loaded on my laptop."&lt;/p&gt;

&lt;p&gt;Actual before and after checks.&lt;/p&gt;

&lt;h2&gt;
  
  
  Production had a surprise waiting
&lt;/h2&gt;

&lt;p&gt;Before the main cutover, I checked for old pricing-tier values that should already have been normalized.&lt;/p&gt;

&lt;p&gt;Production still had legacy &lt;code&gt;expedition&lt;/code&gt; values in both:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;code&gt;product.pricing_tier&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;legacy &lt;code&gt;order.tier&lt;/code&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That was annoying, but useful.&lt;/p&gt;

&lt;p&gt;It meant I could not trust migration history in the abstract. I had to trust the database I was actually about to operate on.&lt;/p&gt;

&lt;p&gt;So I reran the tier-normalization migration before the comment and payment cutover. That cleaned up the remaining visible tier drift before final deployment.&lt;/p&gt;

&lt;p&gt;It is a small example, but it captures something important: production work gets easier the moment you stop arguing with reality.&lt;/p&gt;

&lt;h2&gt;
  
  
  The comment migration
&lt;/h2&gt;

&lt;p&gt;The comment migration looked straightforward on paper and high-risk in practice.&lt;/p&gt;

&lt;p&gt;Old comment data had to be split into four tables:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;code&gt;product_comment&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;product_comment_like&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;blog_comment&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;blog_comment_like&lt;/code&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The schema work itself was not the scary part. The real risk was silently dropping relationships or detaching data during the move.&lt;/p&gt;

&lt;p&gt;So I checked what actually mattered:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;comment counts matched&lt;/li&gt;
&lt;li&gt;comment-like counts matched&lt;/li&gt;
&lt;li&gt;orphan replies were zero&lt;/li&gt;
&lt;li&gt;orphan likes were zero&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The production dataset was still small. I treated it like a serious migration anyway. Row count does not change the standard.&lt;/p&gt;

&lt;h2&gt;
  
  
  The payment migration was the real reason for the window
&lt;/h2&gt;

&lt;p&gt;The bigger job was payments.&lt;/p&gt;

&lt;p&gt;Shipstry now reads runtime payment state through a cleaner domain:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;code&gt;payment_order&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;purchase&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;product_submission_purchase&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;product_upgrade_purchase&lt;/code&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;I wanted this because the old model was trying to do too much with too little structure.&lt;/p&gt;

&lt;p&gt;Once the platform started supporting more than one kind of paid action, I needed the schema to preserve meaning, not just store rows.&lt;/p&gt;

&lt;p&gt;The most important example was upgrade pricing.&lt;/p&gt;

&lt;p&gt;If a maker already paid for an earlier tier, Shipstry should not pretend that money never existed. Their next upgrade should take prior payment into account. From the user's perspective that is obvious. From the schema's perspective it is only obvious if the model supports it.&lt;/p&gt;

&lt;h2&gt;
  
  
  The backfill was where I had to stay conservative
&lt;/h2&gt;

&lt;p&gt;Schema migration alone was not enough.&lt;/p&gt;

&lt;p&gt;The new read paths needed old production data to make sense inside the new payment model. That meant historical orders had to be backfilled into the new tables in a way the current code could actually use.&lt;/p&gt;

&lt;p&gt;There were two things I absolutely did not want to break:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;historical backlinks access&lt;/li&gt;
&lt;li&gt;historical paid-amount continuity for product upgrades&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;I tested the backfill locally against production snapshots before touching live production.&lt;/p&gt;

&lt;p&gt;That helped answer the key question:&lt;/p&gt;

&lt;p&gt;Could the old data safely reconstruct exact historical upgrade transitions?&lt;/p&gt;

&lt;p&gt;Not reliably.&lt;/p&gt;

&lt;p&gt;Legacy &lt;code&gt;order&lt;/code&gt; rows could safely tell me:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;this user bought backlinks access&lt;/li&gt;
&lt;li&gt;this product had a paid submission&lt;/li&gt;
&lt;li&gt;this product had this historical paid total&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;What they could not always tell me, with enough confidence, was the exact boundary between an original submission and a later upgrade in every case.&lt;/p&gt;

&lt;p&gt;So I kept the backfill conservative.&lt;/p&gt;

&lt;p&gt;I backfilled what I could defend. I refused to invent precision that the old data did not actually contain.&lt;/p&gt;

&lt;p&gt;A clean new schema built on guessed history is still guessed history.&lt;/p&gt;

&lt;h2&gt;
  
  
  The one thing that broke
&lt;/h2&gt;

&lt;p&gt;There was exactly one real hiccup during the window.&lt;/p&gt;

&lt;p&gt;The first run of &lt;code&gt;0040_backfill_order_to_payment_domain.sql&lt;/code&gt; failed.&lt;/p&gt;

&lt;p&gt;The data was fine. The logic was fine. The problem was operational: D1 remote execution rejected the explicit &lt;code&gt;BEGIN TRANSACTION&lt;/code&gt; / &lt;code&gt;COMMIT&lt;/code&gt; wrapper in the SQL file.&lt;/p&gt;

&lt;p&gt;That was survivable because the release was already set up to absorb surprises:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;write freeze was still on&lt;/li&gt;
&lt;li&gt;the backup already existed&lt;/li&gt;
&lt;li&gt;nothing was forcing me to reopen writes early&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;So I removed the explicit transaction wrapper, reran the migration, and it completed cleanly.&lt;/p&gt;

&lt;p&gt;No rollback. No restore. Just a contained operational fix.&lt;/p&gt;

&lt;p&gt;That is the kind of surprise I am willing to accept in production. Not "nothing went wrong", but "something went wrong and stayed small".&lt;/p&gt;

&lt;h2&gt;
  
  
  The database gate was the real release gate
&lt;/h2&gt;

&lt;p&gt;I did not consider the release done because the migrations stopped erroring.&lt;/p&gt;

&lt;p&gt;I considered it done only after the database proved that the migration had preserved what mattered.&lt;/p&gt;

&lt;p&gt;The gate looked roughly like this:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;comment counts still matched&lt;/li&gt;
&lt;li&gt;no orphaned comment relationships existed&lt;/li&gt;
&lt;li&gt;no leftover &lt;code&gt;expedition&lt;/code&gt; or &lt;code&gt;admiral&lt;/code&gt; rows remained&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;payment_order&lt;/code&gt; count matched legacy order count&lt;/li&gt;
&lt;li&gt;migrated purchase count matched expectations&lt;/li&gt;
&lt;li&gt;active backlinks purchases matched the old paid backlinks count&lt;/li&gt;
&lt;li&gt;a known historical backlinks buyer still resolved correctly&lt;/li&gt;
&lt;li&gt;a known paid product still reported the exact same historical paid total under the new model&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;I also kept write freeze enabled while checking public routes:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;homepage&lt;/li&gt;
&lt;li&gt;product detail&lt;/li&gt;
&lt;li&gt;explore&lt;/li&gt;
&lt;li&gt;blog&lt;/li&gt;
&lt;li&gt;RSS&lt;/li&gt;
&lt;li&gt;sitemap&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The site stayed readable the whole time, which was the goal from the start.&lt;/p&gt;

&lt;p&gt;Only after the database gates passed did I flip write freeze back off and deploy the final version.&lt;/p&gt;

&lt;h2&gt;
  
  
  What users actually care about
&lt;/h2&gt;

&lt;p&gt;From the outside, this could be described as infrastructure work.&lt;/p&gt;

&lt;p&gt;That is true, but incomplete.&lt;/p&gt;

&lt;p&gt;Users do not care that I created &lt;code&gt;payment_order&lt;/code&gt; and &lt;code&gt;purchase&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;They care that:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;paid access still works&lt;/li&gt;
&lt;li&gt;previous payments still count&lt;/li&gt;
&lt;li&gt;comments still load&lt;/li&gt;
&lt;li&gt;upgrades make sense&lt;/li&gt;
&lt;li&gt;the platform feels stable&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The invisible part is what makes the visible part credible.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I took away from it
&lt;/h2&gt;

&lt;p&gt;The biggest lesson is the obvious one: if a release needs a maintenance switch, build the maintenance switch before release day.&lt;/p&gt;

&lt;p&gt;The second lesson is that production always gets the last word. If the live database says an old tier is still there, then it is still there, no matter how tidy your migration history looks in Git.&lt;/p&gt;

&lt;p&gt;The third lesson is that conservative backfills are underrated. When historical data cannot safely reconstruct perfect semantics, preserve runtime correctness first. Do not make up a cleaner story for the database than the source data can support.&lt;/p&gt;

&lt;p&gt;And maybe the most important lesson is this:&lt;/p&gt;

&lt;p&gt;The release is not the migration script.&lt;/p&gt;

&lt;p&gt;The release is the gates.&lt;/p&gt;

&lt;p&gt;Backups, baselines, validation queries, smoke tests, and the discipline not to reopen writes early just because you are tired.&lt;/p&gt;

&lt;p&gt;That was the part that made a 24-minute maintenance window possible without turning it into a full outage.&lt;/p&gt;

</description>
      <category>database</category>
      <category>devops</category>
      <category>webdev</category>
      <category>ai</category>
    </item>
    <item>
      <title>Building Shipstry: 640 Commits, 9 Days, One Launch</title>
      <dc:creator>rokcso</dc:creator>
      <pubDate>Thu, 26 Mar 2026 09:13:20 +0000</pubDate>
      <link>https://dev.to/rokcso/building-shipstry-640-commits-9-days-one-launch-391i</link>
      <guid>https://dev.to/rokcso/building-shipstry-640-commits-9-days-one-launch-391i</guid>
      <description>&lt;p&gt;On March 3, 2026, I started with an empty folder. On March 11, 2026, Shipstry went live.&lt;/p&gt;

&lt;p&gt;In between: 640 commits, countless cups of coffee, and a lot of lessons learned about building on the edge.&lt;/p&gt;

&lt;p&gt;This is the story of how I built it, the technical decisions I made, and what I learned along the way.&lt;/p&gt;

&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%2Fvdvumu8k68rlnvz7atv3.webp" 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%2Fvdvumu8k68rlnvz7atv3.webp" alt="Shipstry.com" width="800" height="454"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  The Name
&lt;/h2&gt;

&lt;p&gt;Before writing a single line of code, I needed a name.&lt;/p&gt;

&lt;p&gt;I spent an entire afternoon brainstorming with AI. I must have asked for hundreds of suggestions. The AI probably hated me by the end of it.&lt;/p&gt;

&lt;p&gt;I wanted something that captured the essence of what makers do — we &lt;strong&gt;ship&lt;/strong&gt; products. And I wanted it to feel like a registry, a place where products are officially recorded and discovered.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Ship&lt;/strong&gt; + &lt;strong&gt;Registry&lt;/strong&gt; = &lt;strong&gt;Shipstry&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;It sounded nautical, it felt right, and the .com was available. Done.&lt;/p&gt;

&lt;p&gt;The nautical theme evolved into something more organic:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Primary color: Olive Moss (#6B8A67)&lt;/li&gt;
&lt;li&gt;Accent: Warm Sand (#D4A574)&lt;/li&gt;
&lt;li&gt;Pricing tiers: Harbor, Voyage, Expedition, Admiral&lt;/li&gt;
&lt;li&gt;The logo: a geometric sailboat with twin sails&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  The Why
&lt;/h2&gt;

&lt;p&gt;After launching several side projects over the years, I kept running into the same problem: &lt;strong&gt;Product Hunt is great, but it's not built for indie makers anymore.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Big-budget launches dominate. Marketing teams game the algorithm. Great products from solo developers get buried in hours.&lt;/p&gt;

&lt;p&gt;I wanted something different:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;A place that celebrates builders, not marketers&lt;/li&gt;
&lt;li&gt;Weekly cycles instead of daily chaos&lt;/li&gt;
&lt;li&gt;Quality over quantity&lt;/li&gt;
&lt;li&gt;Built by a maker, for makers&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;So I built Shipstry — "The Launch Registry."&lt;/p&gt;

&lt;h2&gt;
  
  
  The Stack Decision
&lt;/h2&gt;

&lt;p&gt;Before writing code, I spent time on stack selection. This is the most important decision you make at the start of a project — it will haunt you for months if you get it wrong.&lt;/p&gt;

&lt;p&gt;I chose:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;TanStack Start&lt;/strong&gt; for the framework. It's a full-stack React framework with file-based routing and excellent TypeScript support. The type safety is incredible — if you change a route, the compiler tells you everywhere that needs updating.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Cloudflare Workers&lt;/strong&gt; for deployment. Edge computing means my users in Singapore, London, and New York all get the same fast experience. No cold starts, global distribution.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Cloudflare D1&lt;/strong&gt; for the database. It's SQLite at the edge. Yes, SQLite — the same database that powers your phone, now running in 300+ locations worldwide. For a product like Shipstry, it's perfect.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Cloudflare R2&lt;/strong&gt; for file storage. When users upload product logos and preview images, they go here. It's S3-compatible but with zero egress fees, which means I don't have to worry about surprise bandwidth bills.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Better Auth&lt;/strong&gt; for authentication. Email/password plus Google OAuth, and it integrates natively with TanStack Start.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Stripe&lt;/strong&gt; for payments, &lt;strong&gt;Resend&lt;/strong&gt; for emails, &lt;strong&gt;Tailwind CSS v4&lt;/strong&gt; for styling, &lt;strong&gt;shadcn/ui&lt;/strong&gt; for components.&lt;/p&gt;

&lt;p&gt;The key insight: &lt;strong&gt;TanStack Start + Cloudflare&lt;/strong&gt; is a powerful combination. You get React's ecosystem with edge performance, and D1 gives you a real database with zero configuration.&lt;/p&gt;

&lt;h2&gt;
  
  
  The First Week
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Day 1-2: Foundation&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The first commits set up the entire foundation — TanStack Start with SSR, Cloudflare Workers adapter, Drizzle ORM, basic routing structure.&lt;/p&gt;

&lt;p&gt;I also built the design system. I didn't want another generic AI landing page with purple gradients. I created a custom "Olive Moss" palette — muted greens and warm grays that feel organic and calm.&lt;/p&gt;

&lt;p&gt;By end of Day 2, I had a working dev server, a distinctive visual identity, and basic page layouts.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Day 3-4: Authentication&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Authentication is always more complicated than you expect.&lt;/p&gt;

&lt;p&gt;Better Auth needs to create its auth instance per-request, not as a singleton. In Cloudflare Workers, each request is isolated anyway, so this architecture actually works well. But figuring that out took a few hours of head-scratching.&lt;/p&gt;

&lt;p&gt;I also designed the database schema upfront. The key decision: separating &lt;strong&gt;drafts&lt;/strong&gt; from &lt;strong&gt;products&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;Drafts have all nullable fields — users can save at any point in the submission flow and return later. Products have required fields — they only exist when fully submitted. This kept the data model clean and the code simple.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Day 4-5: The Submission Flow&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The submission form is the heart of Shipstry. I wanted it to feel smooth, not overwhelming.&lt;/p&gt;

&lt;p&gt;I built a progressive form with collapsible sections. Each section tracks its completion status. Users can save at any point, leave, and pick up where they left off days later.&lt;/p&gt;

&lt;p&gt;For the product description, I integrated Milkdown — a plugin-driven Markdown editor with a custom toolbar. The tricky part was focus management: the toolbar kept stealing focus from the editor. I eventually fixed it by preventing default on mousedown for toolbar buttons.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Day 5: Pricing and Payments&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;I designed a nautical-themed pricing system:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Harbor&lt;/strong&gt; (Free): Basic submission, normal review&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Voyage&lt;/strong&gt; ($9.9): Fast 24-hour review, same-week ship&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Expedition&lt;/strong&gt; ($29): Featured on homepage, 7 days exposure&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Admiral&lt;/strong&gt; ($59): 30 days featured, premium badge&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Stripe integration was straightforward, but the webhook handler needed careful attention. D1 doesn't support nested transactions, so I had to restructure the code to use sequential queries instead of wrapping everything in a transaction.&lt;/p&gt;

&lt;h2&gt;
  
  
  The AI Feature
&lt;/h2&gt;

&lt;p&gt;Filling out product forms is tedious. Users paste a URL and then have to manually enter the name, tagline, description, logo, preview image...&lt;/p&gt;

&lt;p&gt;So I built an AI-powered metadata fetcher.&lt;/p&gt;

&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%2Flg8ua8xkhrthm6vjx4tk.webp" 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%2Flg8ua8xkhrthm6vjx4tk.webp" alt="✨ AI Stow..." width="800" height="454"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;When a user pastes their product URL, the system:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Fetches the page and extracts Open Graph tags&lt;/li&gt;
&lt;li&gt;Sends the information to AI to generate an enhanced, compelling description&lt;/li&gt;
&lt;li&gt;Auto-fills all the form fields&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The user can review and edit everything before submitting. It's not about replacing human input — it's about reducing friction.&lt;/p&gt;

&lt;h3&gt;
  
  
  Multi-Provider Failover
&lt;/h3&gt;

&lt;p&gt;AI APIs are unreliable. They timeout, they rate limit, they have outages.&lt;/p&gt;

&lt;p&gt;I built a failover system that tries multiple AI providers in priority order. If one fails, it automatically tries the next. The configuration is a simple JSON array in environment variables:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nl"&gt;"name"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"openai"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"priority"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nl"&gt;"name"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"claude"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"priority"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nl"&gt;"name"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"gemini"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"priority"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If all providers fail, the form still works — users just fill it manually. Graceful degradation is key.&lt;/p&gt;

&lt;h3&gt;
  
  
  SSRF Protection
&lt;/h3&gt;

&lt;p&gt;Allowing users to fetch arbitrary URLs is dangerous. You don't want someone hitting your internal services through your server.&lt;/p&gt;

&lt;p&gt;I implemented multiple layers of protection:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Block private IP ranges (10.x, 172.x, 192.168.x)&lt;/li&gt;
&lt;li&gt;Block localhost&lt;/li&gt;
&lt;li&gt;Only allow HTTP and HTTPS protocols&lt;/li&gt;
&lt;li&gt;Rate limit: 5 requests per minute per user&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  The Community Features
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Comments and Voting
&lt;/h3&gt;

&lt;p&gt;Comments support nesting — users can reply to replies. I used soft deletes instead of hard deletes, so if a parent comment is removed, the threading structure stays intact.&lt;/p&gt;

&lt;p&gt;For voting, I wanted instant feedback. Nobody wants to wait for a server round-trip to see their vote register.&lt;/p&gt;

&lt;p&gt;I implemented optimistic updates: when you click vote, the UI updates immediately. The server request happens in the background. If it fails, the UI rolls back. This makes the app feel snappy and responsive.&lt;/p&gt;

&lt;h3&gt;
  
  
  Notifications
&lt;/h3&gt;

&lt;p&gt;Users get notified for:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Comments on their products&lt;/li&gt;
&lt;li&gt;Replies to their comments&lt;/li&gt;
&lt;li&gt;Award wins (weekly and monthly)&lt;/li&gt;
&lt;li&gt;Product status changes (approved, rejected)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;For email delivery, I used Cloudflare's &lt;code&gt;waitUntil()&lt;/code&gt; function. This sends the response to the user immediately while the email sends in the background. The user doesn't wait for the email to send.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Final Days
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Caching&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;To reduce database load, I built a caching layer using D1 itself as the cache store. Cached data has TTLs, and mutations trigger automatic cache invalidation.&lt;/p&gt;

&lt;p&gt;This pattern dramatically reduced read load on the main tables during high-traffic periods.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Environment Configuration&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;I centralized all environment variables with validation. In development, the app validates that all required secrets exist and throws clear errors if something is missing. In production, I trust that Cloudflare has the secrets configured.&lt;/p&gt;

&lt;p&gt;This caught several configuration mistakes during development that would have been painful to debug in production.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Launch&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;On March 11, 2026, Shipstry went live.&lt;/p&gt;

&lt;p&gt;The final commits added a launch promo banner with a 50% discount code, and adjusted the ship week logic to allow immediate launches during the launch period.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I Learned
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;TanStack Start is ready for production.&lt;/strong&gt; The framework is stable, well-typed, and SSR works seamlessly with Cloudflare Workers.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;D1 is good enough.&lt;/strong&gt; SQLite at the edge sounds limiting, but for most applications, it's perfect. Zero configuration, fast queries, generous free tier. The main gotcha is no nested transactions.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Edge functions change how you think.&lt;/strong&gt; No global state, &lt;code&gt;waitUntil()&lt;/code&gt; for background tasks, zero cold starts, environment access through imports rather than &lt;code&gt;process.env&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;AI integration is easier than expected.&lt;/strong&gt; With the right abstraction — multi-provider failover and graceful degradation — you can build reliable AI features.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;640 commits in 9 days.&lt;/strong&gt; That's roughly 71 commits per day. Each commit was small, focused, and reversible. The discipline of atomic commits saved me multiple times when I needed to roll back a bad decision.&lt;/p&gt;

&lt;h2&gt;
  
  
  What Happened After Launch
&lt;/h2&gt;

&lt;p&gt;Shipstry has been live for two days.&lt;/p&gt;

&lt;p&gt;In that time, I've been doing link building — submitting to directories, reaching out to communities, getting featured on various platforms.&lt;/p&gt;

&lt;p&gt;The results? &lt;strong&gt;DR went from 0 to 14 in two days.&lt;/strong&gt;&lt;/p&gt;

&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%2Fvan9j10oiosx31ki0jke.webp" 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%2Fvan9j10oiosx31ki0jke.webp" alt="Shipstry on X" width="800" height="1091"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Built with TanStack Start, Cloudflare Workers, D1, R2, and too much coffee.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>ai</category>
      <category>programming</category>
      <category>startup</category>
    </item>
  </channel>
</rss>
