<?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: Visoar Zhang</title>
    <description>The latest articles on DEV Community by Visoar Zhang (@visoar).</description>
    <link>https://dev.to/visoar</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.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F4157732%2F7431bcf6-8b01-4e1e-86a9-6019ad571fed.jpg</url>
      <title>DEV Community: Visoar Zhang</title>
      <link>https://dev.to/visoar</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/visoar"/>
    <language>en</language>
    <item>
      <title>Stripe billing in Next.js: checkout, idempotent webhooks, and release checks</title>
      <dc:creator>Visoar Zhang</dc:creator>
      <pubDate>Fri, 02 Oct 2026 14:39:50 +0000</pubDate>
      <link>https://dev.to/visoar/stripe-billing-in-nextjs-checkout-idempotent-webhooks-and-release-checks-1de4</link>
      <guid>https://dev.to/visoar/stripe-billing-in-nextjs-checkout-idempotent-webhooks-and-release-checks-1de4</guid>
      <description>&lt;p&gt;I help maintain &lt;a href="https://github.com/ullrai/SaaS-Starter" rel="noopener noreferrer"&gt;UllrAI SaaS Starter&lt;/a&gt;, an MIT Next.js project. This article was prepared with AI assistance and checked against the linked implementation.&lt;/p&gt;

&lt;p&gt;Adding a checkout button is the shortest part of a billing integration. Reliability depends on what happens before the redirect, after the provider callback, during retries, and when test and live catalogs diverge. Here are the boundaries used in our starter, with source paths you can inspect.&lt;/p&gt;

&lt;h2&gt;
  
  
  Keep product intent independent from the provider
&lt;/h2&gt;

&lt;p&gt;The application contract lives in &lt;a href="https://github.com/ullrai/SaaS-Starter/blob/main/src/lib/billing/provider.ts" rel="noopener noreferrer"&gt;&lt;code&gt;src/lib/billing/provider.ts&lt;/code&gt;&lt;/a&gt;. Callers provide a tier ID, payment mode, optional billing cycle, authenticated owner, and return URLs. They receive a validated checkout URL rather than a raw provider response.&lt;/p&gt;

&lt;p&gt;The Stripe adapter maps that intent to a Price ID in &lt;a href="https://github.com/ullrai/SaaS-Starter/blob/main/src/lib/billing/stripe/prices.ts" rel="noopener noreferrer"&gt;&lt;code&gt;src/lib/billing/stripe/prices.ts&lt;/code&gt;&lt;/a&gt;. Subscription and one-time purchase flows share the contract while retaining different entitlement behavior. This keeps business code separate without pretending every provider has identical concepts.&lt;/p&gt;

&lt;h2&gt;
  
  
  Validate checkout before leaving the application
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;POST /api/billing/checkout&lt;/code&gt; is an authenticated boundary. It validates the feature flag, session, supported tier/payment mode/billing cycle, the active environment's Price ID, and conflicting entitlements. Repeated browser attempts reuse a request ID to avoid accidental duplicate checkout sessions.&lt;/p&gt;

&lt;p&gt;The client allowlists the returned redirect host before navigation. A successful HTTP response containing an unexpected URL becomes a controlled error.&lt;/p&gt;

&lt;h2&gt;
  
  
  Separate test and live catalogs
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;STRIPE_ENVIRONMENT&lt;/code&gt; selects &lt;code&gt;test_mode&lt;/code&gt; or &lt;code&gt;live_mode&lt;/code&gt;. Price IDs are stored separately. The sync command updates only the active namespace:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;pnpm stripe:sync-products
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Review and commit the resulting catalog configuration. A missing live Price ID must not fall back to the test catalog or cause an application request to create Products or Prices.&lt;/p&gt;

&lt;h2&gt;
  
  
  Treat webhooks as durable billing evidence
&lt;/h2&gt;

&lt;p&gt;The browser returning from checkout is feedback, not durable billing evidence. The webhook verifies the Stripe signature, records the provider event ID, and processes the event idempotently. Relevant transitions include checkout completion, subscription renewal or cancellation, payment failure, refunds, and disputes.&lt;/p&gt;

&lt;p&gt;Ownership metadata is allowlisted and checked against the checkout record. Product and payment-mode metadata must match the configured tier before an entitlement is written. Retries distinguish an event that was already processed from processing that failed before completion, so replay does not apply the same entitlement twice.&lt;/p&gt;

&lt;p&gt;Inspect the &lt;a href="https://github.com/ullrai/SaaS-Starter/tree/main/src/lib/billing/stripe" rel="noopener noreferrer"&gt;Stripe implementation&lt;/a&gt; and &lt;a href="https://github.com/ullrai/SaaS-Starter/tree/main/src/app/api/billing/webhooks/stripe" rel="noopener noreferrer"&gt;webhook route&lt;/a&gt; when adapting this flow.&lt;/p&gt;

&lt;h2&gt;
  
  
  Verify the return path without trusting it
&lt;/h2&gt;

&lt;p&gt;The payment status page polls the application API using the checkout reference. The server retrieves the checkout and verifies ownership. The client displays success, pending, failed, or cancelled from this controlled result.&lt;/p&gt;

&lt;p&gt;Analytics records &lt;code&gt;payment_start&lt;/code&gt; before redirect and &lt;code&gt;payment_success&lt;/code&gt; only when the controlled status reaches success. Webhooks and database rows remain the accounting source; analytics events describe the journey.&lt;/p&gt;

&lt;h2&gt;
  
  
  Release checks
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;Configure the live Stripe key and endpoint signing secret.&lt;/li&gt;
&lt;li&gt;Sync and review the live Price IDs.&lt;/li&gt;
&lt;li&gt;Register &lt;code&gt;/api/billing/webhooks/stripe&lt;/code&gt; as the production webhook endpoint.&lt;/li&gt;
&lt;li&gt;Exercise subscription and one-time checkouts with an authenticated test account.&lt;/li&gt;
&lt;li&gt;Replay a webhook and verify that database state changes only once.&lt;/li&gt;
&lt;li&gt;Test cancellation, refund, and failed-payment paths.&lt;/li&gt;
&lt;li&gt;Run &lt;code&gt;pnpm lint&lt;/code&gt;, &lt;code&gt;pnpm type-check&lt;/code&gt;, &lt;code&gt;pnpm test&lt;/code&gt;, and &lt;code&gt;pnpm build&lt;/code&gt;.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Keep secrets and customer payment data out of verification logs.&lt;/p&gt;

&lt;p&gt;Add a new tier through the typed product catalog and its tests. A new provider should implement the existing contract while preserving ownership, validation, and idempotency.&lt;/p&gt;

&lt;p&gt;The &lt;a href="https://starter.ullrai.com/blog/stripe-nextjs-billing-production-guide" rel="noopener noreferrer"&gt;original billing guide&lt;/a&gt;, &lt;a href="https://starter.ullrai.com/blog/nextjs-16-saas-starter-architecture" rel="noopener noreferrer"&gt;architecture walkthrough&lt;/a&gt;, and &lt;a href="https://starter.ullrai.com/blog/saas-starter-kit-developer-guide" rel="noopener noreferrer"&gt;developer guide&lt;/a&gt; provide the surrounding setup and deployment details.&lt;/p&gt;

</description>
      <category>nextjs</category>
    </item>
  </channel>
</rss>
