<?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: Jake Mcdonald</title>
    <description>The latest articles on DEV Community by Jake Mcdonald (@jakemcdonald).</description>
    <link>https://dev.to/jakemcdonald</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%2F4217546%2Fa9eade7d-f8ac-423a-8781-7875de956c0d.png</url>
      <title>DEV Community: Jake Mcdonald</title>
      <link>https://dev.to/jakemcdonald</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/jakemcdonald"/>
    <language>en</language>
    <item>
      <title>Best Next.js SaaS boilerplates in 2026</title>
      <dc:creator>Jake Mcdonald</dc:creator>
      <pubDate>Fri, 30 Oct 2026 15:18:11 +0000</pubDate>
      <link>https://dev.to/jakemcdonald/best-nextjs-saas-boilerplates-in-2026-35n8</link>
      <guid>https://dev.to/jakemcdonald/best-nextjs-saas-boilerplates-in-2026-35n8</guid>
      <description>&lt;p&gt;By Jake Mcdonald, founder of ShipEmber Â· Updated October 30, 2026 Â· About a 10-minute read Â· Prices checked against vendor sites this week&lt;/p&gt;

&lt;p&gt;If you're about to build a SaaS on Next.js, you will eventually ask: do I wire auth and Stripe myself, use a free starter, or buy a boilerplate? This comparison covers six options that keep coming up in 2026 â€” including ShipEmber, which I sell. Prices below were verified from each product's own site (or GitHub for free kits) around October 30, 2026. If a figure couldn't be confirmed live, I say so.&lt;/p&gt;

&lt;h2&gt;
  
  
  What actually matters in a Next.js SaaS starter
&lt;/h2&gt;

&lt;p&gt;Feature checklists get long fast. For a first paid product, these five things decide whether you ship this month or next quarter:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Auth that works on the server.&lt;/strong&gt; Sessions you can trust in Route Handlers and Server Components.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Stripe subscriptions done right.&lt;/strong&gt; Checkout, signed webhooks with a raw body, idempotency, and plan state in your own database.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A gated dashboard.&lt;/strong&gt; Free vs Pro checked where the data lives, not only behind a hidden button.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A stack you already know.&lt;/strong&gt; Next.js App Router, TypeScript, and a familiar ORM beat a clever monorepo you have to relearn.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Price vs time saved.&lt;/strong&gt; A $350 kit that saves two weeks is cheap. A $350 kit that forces you to unlearn patterns is not.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  At a glance
&lt;/h2&gt;

&lt;p&gt;Prices are one-time unless noted. "See site" means I couldn't lock a single current number from the vendor page at write time.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Kit&lt;/th&gt;
&lt;th&gt;Price (verified)&lt;/th&gt;
&lt;th&gt;Auth + Stripe&lt;/th&gt;
&lt;th&gt;Best for&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;ShipEmber&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;$49 launch week, then $79&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;Minimal, fast solo MVP&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;ShipFast&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Starter $199 / All-in $249&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;Indie SaaS + marketing extras&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Makerkit&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Pro $349 / Teams $649&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;Multi-tenant B2B&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;supastarter&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Solo $349 / Startup $799 / Agency $1,499&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;Full B2B / agency work&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;nextjs/saas-starter&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Free (MIT)&lt;/td&gt;
&lt;td&gt;Yes (basic)&lt;/td&gt;
&lt;td&gt;Learning / reference&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;create-t3-app&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Free (MIT)&lt;/td&gt;
&lt;td&gt;Auth optional; no Stripe&lt;/td&gt;
&lt;td&gt;Typesafe scaffold only&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  ShipEmber â€” low-cost, minimal, fast to ship
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Price:&lt;/strong&gt; $49 during launch week, then $79. One-time download. Lifetime updates to v1. 14-day refund if it doesn't work as described.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What you get:&lt;/strong&gt; Next.js App Router + TypeScript, Auth.js v5 (Google, GitHub, magic links), Stripe Checkout + customer portal + signature-verified idempotent webhooks, Prisma + Postgres, a protected dashboard with Free/Pro gating, marketing pages, one-file rebrand, tests, and CI. Instant download after payment â€” no GitHub invite wait.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Honest take:&lt;/strong&gt; ShipEmber is intentionally small. It does not ship organizations, i18n, an admin CRM, or five payment providers. If you want a weekend MVP with login, billing, and a gated Pro page, that is the point. If you need multi-tenant B2B on day one, look at Makerkit or supastarter instead.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://buy.stripe.com/00weVc3I4fgmdBJcie3ks00" rel="noopener noreferrer"&gt;Get ShipEmber, $49&lt;/a&gt; &lt;a href="https://shipember.com/pricing.html" rel="noopener noreferrer"&gt;See pricing details&lt;/a&gt; Â· &lt;a href="https://www.youtube.com/watch?v=telI3nHd9Tg" rel="noopener noreferrer"&gt;Watch the walkthrough&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  ShipFast
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Price (verified on shipfa.st):&lt;/strong&gt; Starter &lt;strong&gt;$199&lt;/strong&gt;, All-in &lt;strong&gt;$249&lt;/strong&gt;, plus a ShipFast + CodeFast bundle listed at &lt;strong&gt;$299&lt;/strong&gt;. One-time.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What you get:&lt;/strong&gt; A popular indie Next.js boilerplate with auth (Google OAuth and magic links), Stripe or Lemon Squeezy, MongoDB or Supabase, emails, SEO/blog helpers, UI components, and JS or TS / App or Pages Router variants. The All-in tier adds Discord community access and partner discounts.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Honest take:&lt;/strong&gt; ShipFast is still the name many makers compare against. It covers the "ship an indie SaaS" surface area well. Tradeoffs: it is several times ShipEmber's price, community extras sit behind the higher tier, and public update cadence has slowed (the FAQ on the site noted the last update months ago when we checked). Buy it for the code and docs you get today, not for a heavy roadmap.&lt;/p&gt;

&lt;h2&gt;
  
  
  Makerkit
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Price (verified on makerkit.dev/pricing):&lt;/strong&gt; Pro &lt;strong&gt;$349&lt;/strong&gt;, Teams &lt;strong&gt;$649&lt;/strong&gt;. One-time, lifetime updates. A free MIT "Lite" repo exists for evaluating the architecture.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What you get:&lt;/strong&gt; A serious multi-tenant kit â€” teams, roles, invitations, billing, super admin, marketing site, blog, docs, and AI agent rules / MCP tooling. Stack choices include Supabase, Drizzle, or Prisma variants on Next.js (and related frameworks depending on the kit).&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Honest take:&lt;/strong&gt; If you're building B2B SaaS with organizations and RBAC, Makerkit is one of the strongest options in this list. The complexity matches the price: you get more architecture, and you will spend more time learning it than a minimal kit. Freelancers building for clients should read their license terms carefully (client licensing rules differ from solo unlimited-project kits).&lt;/p&gt;

&lt;h2&gt;
  
  
  supastarter
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Price (verified on supastarter.dev):&lt;/strong&gt; Solo &lt;strong&gt;$349&lt;/strong&gt; (1 seat), Startup &lt;strong&gt;$799&lt;/strong&gt; (up to 5 seats), Agency &lt;strong&gt;$1,499&lt;/strong&gt; (up to 10 seats). One-time lifetime license. Framework options include Next.js (and others on their site).&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What you get:&lt;/strong&gt; A wide production surface: auth (password, passkeys, OAuth, 2FA), organizations, billing across multiple providers, i18n, admin, email, storage, analytics hooks, E2E testing, docs, and an agent-oriented monorepo layout.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Honest take:&lt;/strong&gt; This is the "full kitchen" kit. Agencies and teams that bill clients can justify the higher tiers. Solo founders validating a single B2C idea will pay for a lot of surface area they may never use. If you want everything production-shaped on day one and can absorb the learning curve, it belongs on your shortlist.&lt;/p&gt;

&lt;h2&gt;
  
  
  nextjs/saas-starter (Vercel / Next.js)
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Price:&lt;/strong&gt; Free, MIT. Repo: &lt;a href="https://github.com/nextjs/saas-starter" rel="noopener noreferrer"&gt;github.com/nextjs/saas-starter&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What you get:&lt;/strong&gt; Official-ish reference starter with Next.js, Postgres + Drizzle, Stripe Checkout and Customer Portal, email/password auth with JWT cookies, dashboard CRUD, basic team Owner/Member roles, and shadcn/ui.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Honest take:&lt;/strong&gt; Excellent for learning how a clean Next.js + Stripe + Postgres app is structured. It is not a polished commercial product shell (marketing polish, OAuth providers, idempotent event tables, and rebrand tooling vary). Free is hard to beat for study; many people still graduate to a paid kit once they want production auth options and tighter billing patterns.&lt;/p&gt;

&lt;h2&gt;
  
  
  create-t3-app
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Price:&lt;/strong&gt; Free, MIT. CLI: &lt;code&gt;npm create t3-app@latest&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What you get:&lt;/strong&gt; An interactive scaffold for the T3 stack â€” Next.js, TypeScript, optional tRPC, Prisma or Drizzle, NextAuth, and Tailwind. It is intentionally not a SaaS product kit.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Honest take:&lt;/strong&gt; Perfect if you want a typesafe foundation and will wire Stripe, emails, and marketing yourself (see our &lt;a href="https://shipember.com/blog/nextjs-stripe-subscription-starter.html" rel="noopener noreferrer"&gt;Stripe subscriptions guide&lt;/a&gt;). Wrong tool if your goal is "checkout and Pro gating by Friday" without writing that plumbing.&lt;/p&gt;

&lt;h2&gt;
  
  
  How to choose
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Budget under ~$100, need auth + Stripe + dashboard now:&lt;/strong&gt; ShipEmber.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Indie SaaS with marketing extras and a big Discord:&lt;/strong&gt; ShipFast.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Multi-tenant B2B with teams and admin:&lt;/strong&gt; Makerkit (or supastarter if you want the widest feature set / multi-framework options).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Learning or forking a free reference:&lt;/strong&gt; nextjs/saas-starter.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Scaffold only; you'll build billing yourself:&lt;/strong&gt; create-t3-app.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;There is no single "best" boilerplate â€” only the best fit for the product you're shipping this quarter. Expensive kits aren't automatically better for a solo MVP; free kits aren't free if you spend two weeks recreating Checkout webhooks.&lt;/p&gt;

&lt;h2&gt;
  
  
  If you want the small paid path
&lt;/h2&gt;

&lt;p&gt;ShipEmber exists for one job: get you from empty folder to a deployable Next.js SaaS with login, Stripe subscriptions, and a gated dashboard without a $300â€“$1,500 invoice. Launch-week price is $49 (then $79). One payment, instant download, unlimited personal and client projects (don't resell the kit itself).&lt;/p&gt;

&lt;p&gt;&lt;a href="https://buy.stripe.com/00weVc3I4fgmdBJcie3ks00" rel="noopener noreferrer"&gt;Get ShipEmber, $49&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Prefer to see it first? &lt;a href="https://www.youtube.com/watch?v=telI3nHd9Tg" rel="noopener noreferrer"&gt;Watch the walkthrough&lt;/a&gt; or &lt;a href="https://shipember.com/index.html#what-we-do" rel="noopener noreferrer"&gt;play the demo on the homepage&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;Price note: ShipFast prices verified on shipfa.st; Makerkit on makerkit.dev/pricing; supastarter on supastarter.dev; free kits via their GitHub licenses. Vendor pages change â€” if a number looks off later, trust the vendor site.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Originally published on &lt;a href="https://shipember.com/blog/best-nextjs-saas-boilerplates.html" rel="noopener noreferrer"&gt;shipember.com&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>nextjs</category>
      <category>saas</category>
      <category>webdev</category>
      <category>javascript</category>
    </item>
    <item>
      <title>How to add Stripe subscriptions to a Next.js App Router app (2026)</title>
      <dc:creator>Jake Mcdonald</dc:creator>
      <pubDate>Fri, 30 Oct 2026 15:12:18 +0000</pubDate>
      <link>https://dev.to/jakemcdonald/how-to-add-stripe-subscriptions-to-a-nextjs-app-router-app-2026-4mb3</link>
      <guid>https://dev.to/jakemcdonald/how-to-add-stripe-subscriptions-to-a-nextjs-app-router-app-2026-4mb3</guid>
      <description>&lt;p&gt;By Jake Mcdonald, founder of ShipEmber Â· Updated October 30, 2026 Â· About a 14-minute read&lt;/p&gt;

&lt;p&gt;Adding Stripe subscriptions to a Next.js App Router app doesn't take much code. It goes wrong in the small details: a webhook that parses the body before verifying it, a handler that runs the same event twice, or a "Pro" check that only exists in a client component. This guide covers a complete &lt;strong&gt;Next.js Stripe subscription&lt;/strong&gt; flow with stripe-node and Route Handlers, and keeps the parts that matter on the server.&lt;/p&gt;

&lt;p&gt;Here's the plan:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;A route that creates a Checkout Session in &lt;code&gt;subscription&lt;/code&gt; mode.&lt;/li&gt;
&lt;li&gt;A webhook route that reads the raw body, verifies the signature, and skips events it has already processed.&lt;/li&gt;
&lt;li&gt;Syncing subscription state into your own database.&lt;/li&gt;
&lt;li&gt;Gating a Pro page on the server, not in the browser.&lt;/li&gt;
&lt;li&gt;The customer portal for upgrades, cancellations, and card changes.&lt;/li&gt;
&lt;li&gt;Separate webhook secrets for local, test, and production (including Vercel).&lt;/li&gt;
&lt;li&gt;The pitfalls that cost people an afternoon.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The examples assume TypeScript, Prisma with Postgres, and some kind of server-side &lt;code&gt;auth()&lt;/code&gt; helper (Auth.js v5 has one). Swap in your own ORM or auth setup as needed.&lt;/p&gt;

&lt;h2&gt;
  
  
  Setup
&lt;/h2&gt;

&lt;p&gt;Install the SDK and create one shared client. Use the server-only package so a careless import can't pull your secret key into a client bundle.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;npm &lt;span class="nb"&gt;install &lt;/span&gt;stripe server-only
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// lib/stripe.ts&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;server-only&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="nx"&gt;Stripe&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;stripe&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;stripe&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;Stripe&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;STRIPE_SECRET_KEY&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Recent versions of stripe-node pin a default API version to each SDK release, so you usually don't need to pass &lt;code&gt;apiVersion&lt;/code&gt;. When you upgrade the package, read the changelog. Some response shapes change between API versions, and one of those changes is covered in the pitfalls below.&lt;/p&gt;

&lt;p&gt;In the Stripe Dashboard, create a Product with a recurring Price, for example $12/month. Copy the price ID (&lt;code&gt;price_...&lt;/code&gt;) into an env var. Don't hardcode amounts in your app. Stripe owns the price.&lt;/p&gt;

&lt;p&gt;You'll need a few columns on your user (or team) table:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;model User {
  id                   String    @id @default(cuid())
  email                String    @unique
  stripeCustomerId     String?   @unique
  stripeSubscriptionId String?   @unique
  stripePriceId        String?
  subscriptionStatus   String?   // active, trialing, past_due, canceled...
  currentPeriodEnd     DateTime?
}

model StripeEvent {
  id        String   @id      // the evt_... ID
  type      String
  createdAt DateTime @default(now())
}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  1. The checkout session route
&lt;/h2&gt;

&lt;p&gt;The browser should never decide which price it pays. It only sends a request. The server looks up the user, finds or creates their Stripe customer, and returns a Checkout URL.&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;// app/api/checkout/route.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;NextResponse&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;next/server&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;auth&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;@/auth&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;stripe&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/stripe&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;db&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/db&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&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;POST&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;session&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;auth&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;session&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nx"&gt;user&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="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;NextResponse&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;error&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Unauthorized&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;status&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;401&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;user&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;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;user&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;findUniqueOrThrow&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;where&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;session&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;user&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="p"&gt;});&lt;/span&gt;

  &lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;customerId&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;user&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;stripeCustomerId&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;customerId&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;customer&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;stripe&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;customers&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;email&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;user&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;email&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;metadata&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;userId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;user&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="p"&gt;});&lt;/span&gt;
    &lt;span class="nx"&gt;customerId&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;customer&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="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;user&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;update&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;where&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;user&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;data&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;stripeCustomerId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;customerId&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;checkout&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;stripe&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;checkout&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;sessions&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;mode&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;subscription&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;customer&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;customerId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;client_reference_id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;user&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;line_items&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[{&lt;/span&gt; &lt;span class="na"&gt;price&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;STRIPE_PRICE_PRO&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;quantity&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="p"&gt;}],&lt;/span&gt;
    &lt;span class="na"&gt;subscription_data&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;metadata&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;userId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;user&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="p"&gt;},&lt;/span&gt;
    &lt;span class="na"&gt;success_url&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;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;APP_URL&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;/dashboard?checkout=success`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;cancel_url&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;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;APP_URL&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;/pricing`&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;json&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;url&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;checkout&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A few notes:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Create the customer yourself and reuse it.&lt;/strong&gt; If you skip &lt;code&gt;customer&lt;/code&gt;, Checkout creates a new customer every time, and soon one person has four customer records.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Put your user ID on the subscription metadata.&lt;/strong&gt; Subscription events will carry it, so the webhook doesn't need to guess which user an event belongs to.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The success page is not proof of payment.&lt;/strong&gt; Anyone can visit &lt;code&gt;?checkout=success&lt;/code&gt;. Only the webhook updates the plan.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;On the client, a button POSTs to this route and then runs &lt;code&gt;window.location.href = url&lt;/code&gt;. A Server Action that calls &lt;code&gt;redirect(checkout.url!)&lt;/code&gt; also works.&lt;/p&gt;

&lt;h2&gt;
  
  
  2. Stripe webhooks in a Next.js App Router route handler
&lt;/h2&gt;

&lt;p&gt;This is the route people get wrong most often when wiring &lt;strong&gt;Stripe webhooks for Next.js App Router&lt;/strong&gt;. Stripe signs the &lt;em&gt;exact bytes&lt;/em&gt; it sends. If you parse the JSON and stringify it again, the signature check fails. In the App Router you don't need to disable a body parser. Just read the body with &lt;code&gt;request.text()&lt;/code&gt; (or &lt;code&gt;req.text()&lt;/code&gt;) and don't call &lt;code&gt;req.json()&lt;/code&gt; before &lt;code&gt;constructEvent&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;// app/api/stripe/webhook/route.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;headers&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;next/headers&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="nx"&gt;Stripe&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;stripe&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;Prisma&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;@prisma/client&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;stripe&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/stripe&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;db&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/db&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;syncSubscription&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/billing&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;runtime&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;nodejs&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&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;POST&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;req&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="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;body&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;req&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;text&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt; &lt;span class="c1"&gt;// raw body. Do not use req.json().&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;signature&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&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;stripe-signature&lt;/span&gt;&lt;span class="dl"&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="nx"&gt;signature&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Response&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Missing signature&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;status&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;400&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;

  &lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;Stripe&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;Event&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;event&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;stripe&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;webhooks&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;constructEvent&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
      &lt;span class="nx"&gt;body&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="nx"&gt;signature&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;STRIPE_WEBHOOK_SECRET&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;
    &lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;catch&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Response&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Invalid signature&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;status&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;400&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="c1"&gt;// Idempotency: record the event ID first. A duplicate hits the unique constraint.&lt;/span&gt;
  &lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;stripeEvent&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;data&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;event&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;type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="kd"&gt;type&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;catch &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;e&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;e&lt;/span&gt; &lt;span class="k"&gt;instanceof&lt;/span&gt; &lt;span class="nx"&gt;Prisma&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;PrismaClientKnownRequestError&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;code&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;P2002&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="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Response&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Already processed&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;status&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;200&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;switch &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="kd"&gt;type&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;checkout.session.completed&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="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;s&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;event&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;object&lt;/span&gt; &lt;span class="kd"&gt;as &lt;/span&gt;&lt;span class="nx"&gt;Stripe&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;Checkout&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;Session&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;s&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;subscription&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;subscription&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
          &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;syncSubscription&lt;/span&gt;&lt;span class="p"&gt;(&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;subscription&lt;/span&gt; &lt;span class="kd"&gt;as &lt;/span&gt;&lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;
        &lt;span class="k"&gt;break&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;customer.subscription.created&lt;/span&gt;&lt;span class="dl"&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;customer.subscription.updated&lt;/span&gt;&lt;span class="dl"&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;customer.subscription.deleted&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="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;sub&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;event&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;object&lt;/span&gt; &lt;span class="kd"&gt;as &lt;/span&gt;&lt;span class="nx"&gt;Stripe&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;Subscription&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;syncSubscription&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;sub&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="k"&gt;break&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="k"&gt;catch &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;err&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="c1"&gt;// Let Stripe retry: un-record the event and return a 500.&lt;/span&gt;
    &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;stripeEvent&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;delete&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;where&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;event&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="p"&gt;}).&lt;/span&gt;&lt;span class="k"&gt;catch&lt;/span&gt;&lt;span class="p"&gt;(()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{});&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Response&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Handler failed&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;status&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;500&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Response&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;ok&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;status&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;200&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;Why this shape works:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Verify the signature before you trust anything.&lt;/strong&gt; &lt;code&gt;constructEvent&lt;/code&gt; checks the HMAC and the timestamp tolerance. Without it, anyone who finds your URL can send a fake &lt;code&gt;customer.subscription.updated&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Stripe delivers at least once.&lt;/strong&gt; Retries, timeouts, and replays from the Dashboard all mean the same event can arrive more than once. A table keyed by &lt;code&gt;event.id&lt;/code&gt; with a unique constraint is the simplest dedupe that holds up under concurrent deliveries.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Handle &lt;code&gt;checkout.session.completed&lt;/code&gt; plus &lt;code&gt;customer.subscription.updated&lt;/code&gt; / &lt;code&gt;deleted&lt;/code&gt;.&lt;/strong&gt; Checkout grants the first subscription; subscription events keep status in sync when the customer upgrades, cancels, or fails a renewal.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Return a 500 on real failures&lt;/strong&gt; and remove the dedupe row so the retry can run. If you return 200 after a failure, Stripe considers the event delivered and won't retry.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Use &lt;code&gt;runtime = "nodejs"&lt;/code&gt;.&lt;/strong&gt; stripe-node can verify on edge runtimes with &lt;code&gt;constructEventAsync&lt;/code&gt;, but the Node runtime has fewer surprises.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  3. Syncing plan state into your own DB
&lt;/h2&gt;

&lt;p&gt;Don't build state from event payloads one field at a time. Events can arrive out of order. An &lt;code&gt;updated&lt;/code&gt; event can show up before &lt;code&gt;created&lt;/code&gt;. The reliable approach is to &lt;strong&gt;treat the event as a signal and fetch the current subscription from Stripe&lt;/strong&gt;, then write the whole thing:&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;// lib/billing.ts&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;server-only&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;stripe&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/stripe&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;db&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/db&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&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;syncSubscription&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;subscriptionId&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="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;sub&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;stripe&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;subscriptions&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;retrieve&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;subscriptionId&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;item&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;sub&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;items&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;data&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="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;customerId&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;typeof&lt;/span&gt; &lt;span class="nx"&gt;sub&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;customer&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;string&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="nx"&gt;sub&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;customer&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;sub&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;customer&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="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;user&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;update&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
    &lt;span class="na"&gt;where&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;stripeCustomerId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;customerId&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="na"&gt;data&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="na"&gt;stripeSubscriptionId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;sub&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;stripePriceId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;item&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nx"&gt;price&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&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="na"&gt;subscriptionStatus&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;sub&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;status&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="c1"&gt;// On recent API versions, period dates are on the subscription item.&lt;/span&gt;
      &lt;span class="na"&gt;currentPeriodEnd&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;item&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="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;item&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;current_period_end&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;1000&lt;/span&gt;&lt;span class="p"&gt;)&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="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;Matching on &lt;code&gt;stripeCustomerId&lt;/code&gt; works because you created the customer in step 1. The &lt;code&gt;userId&lt;/code&gt; in metadata is a backup if you ever need it.&lt;/p&gt;

&lt;p&gt;Then define in one place what "paid" means:&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;// lib/plan.ts&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;PRO_PRICES&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;Set&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;STRIPE_PRICE_PRO&lt;/span&gt;&lt;span class="o"&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;isPro&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;u&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nl"&gt;subscriptionStatus&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="nl"&gt;stripePriceId&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="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="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;u&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;subscriptionStatus&lt;/span&gt; &lt;span class="o"&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="o"&gt;||&lt;/span&gt; &lt;span class="nx"&gt;u&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;subscriptionStatus&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;trialing&lt;/span&gt;&lt;span class="dl"&gt;"&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;u&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;stripePriceId&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;PRO_PRICES&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;u&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;stripePriceId&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;Whether &lt;code&gt;past_due&lt;/code&gt; still gets access is a business decision. Many apps allow a grace period while Stripe's Smart Retries try the card again. Whatever you choose, decide it here and nowhere else.&lt;/p&gt;

&lt;h2&gt;
  
  
  4. Gating a Pro page server-side
&lt;/h2&gt;

&lt;p&gt;Hiding a button in a client component is UI, not access control. The check has to run on the server, right next to the data or action it protects. In the App Router, that means Server Components, Server Actions, and Route Handlers.&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;// app/dashboard/reports/page.tsx&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;redirect&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;next/navigation&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;auth&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;@/auth&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;db&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/db&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;isPro&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/plan&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="k"&gt;default&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;ReportsPage&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;session&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;auth&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;session&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nx"&gt;user&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="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;/login&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;user&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;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;user&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;findUniqueOrThrow&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;where&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;session&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;user&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="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;isPro&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;user&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;/pricing&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;Reports&lt;/span&gt; &lt;span class="na"&gt;userId&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;user&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Put the same check in every Server Action and API route that does paid work. A small &lt;code&gt;requirePro()&lt;/code&gt; helper that throws or redirects keeps this short.&lt;/p&gt;

&lt;p&gt;Why not middleware? Middleware is fine for cheap redirects, but it's a poor place to do the only access check. It runs separately from the code it protects, matchers are easy to get wrong, and a database call there adds latency to every request. Also, in March 2025 Next.js patched a vulnerability (CVE-2025-29927) that let a crafted header skip middleware entirely. Check access where the data is read.&lt;/p&gt;

&lt;h2&gt;
  
  
  5. The customer portal
&lt;/h2&gt;

&lt;p&gt;Don't build your own screens for cancel, upgrade, and update card. Stripe's customer portal handles all of them. Turn on the features you want under Settings â†’ Billing â†’ Customer portal, then create a session on the server:&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;// app/api/portal/route.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;NextResponse&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;next/server&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;auth&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;@/auth&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;stripe&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/stripe&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;db&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/db&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&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;POST&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;session&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;auth&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;session&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nx"&gt;user&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="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;NextResponse&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;error&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Unauthorized&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;status&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;401&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;user&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;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;user&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;findUniqueOrThrow&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;where&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;session&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;user&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="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;user&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;stripeCustomerId&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;NextResponse&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;error&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;No customer&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;status&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;400&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;portal&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;stripe&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;billingPortal&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;sessions&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;customer&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;user&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;stripeCustomerId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;return_url&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;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;APP_URL&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;/dashboard/billing`&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;json&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;url&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;portal&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Anything the user changes in the portal sends a &lt;code&gt;customer.subscription.updated&lt;/code&gt; or &lt;code&gt;.deleted&lt;/code&gt; event. Your existing webhook handles it, so there's no extra code to write. If you allow plan switching in the portal, add those prices to the portal configuration and to &lt;code&gt;PRO_PRICES&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  6. Separate webhook secrets: local, test, and production on Vercel
&lt;/h2&gt;

&lt;p&gt;Each Stripe webhook endpoint has its own signing secret (&lt;code&gt;whsec_...&lt;/code&gt;). Mixing them up is the most common reason &lt;code&gt;constructEvent&lt;/code&gt; fails in production after it worked on your laptop.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Local:&lt;/strong&gt; &lt;code&gt;stripe listen --forward-to localhost:3000/api/stripe/webhook&lt;/code&gt; prints a CLI secret. Put that in &lt;code&gt;.env.local&lt;/code&gt; only.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Test mode Dashboard endpoint:&lt;/strong&gt; Create an endpoint pointing at your staging URL (or a temporary tunnel). Use that secret in your Preview / test env vars.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Live mode production:&lt;/strong&gt; Create a separate Live endpoint for &lt;code&gt;https://yourdomain.com/api/stripe/webhook&lt;/code&gt;. Copy its secret into Vercel Production env as &lt;code&gt;STRIPE_WEBHOOK_SECRET&lt;/code&gt;. Do not reuse the CLI secret or the test-mode secret.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;On Vercel, set Production and Preview env vars separately. Redeploy after changing secrets. In the Stripe Dashboard, subscribe the endpoint to at least &lt;code&gt;checkout.session.completed&lt;/code&gt;, &lt;code&gt;customer.subscription.updated&lt;/code&gt;, and &lt;code&gt;customer.subscription.deleted&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  7. Common pitfalls
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Parsing the body before verifying it.&lt;/strong&gt; &lt;code&gt;req.json()&lt;/code&gt; then &lt;code&gt;JSON.stringify&lt;/code&gt; doesn't give you back the same bytes. Use &lt;code&gt;req.text()&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Using the wrong webhook secret.&lt;/strong&gt; CLI, test, and live secrets are three different values. See section 6.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Granting access on the success URL.&lt;/strong&gt; Users can close the tab, refresh the page, or type the URL. Only the webhook should grant access.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Not handling duplicate events.&lt;/strong&gt; Two deliveries of the same event shouldn't send two welcome emails or grant credits twice. Store &lt;code&gt;event.id&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Trusting event order.&lt;/strong&gt; Fetch the subscription again instead of applying event payloads one after another.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Reading &lt;code&gt;current_period_end&lt;/code&gt; from the subscription.&lt;/strong&gt; In recent API versions it moved to subscription items. If you upgraded stripe-node and the field is now &lt;code&gt;undefined&lt;/code&gt;, this is why.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Creating duplicate customers.&lt;/strong&gt; Always pass &lt;code&gt;customer&lt;/code&gt; to Checkout once you have one.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Middleware or redirects intercepting &lt;code&gt;/api/stripe/webhook&lt;/code&gt;.&lt;/strong&gt; Auth middleware that redirects the webhook to &lt;code&gt;/login&lt;/code&gt; breaks it. So does a trailing-slash redirect, because Stripe doesn't follow redirects.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Doing slow work inside the handler.&lt;/strong&gt; Stripe expects a quick 2xx. If you send emails or call other APIs, queue that work or keep it short.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Checking the plan only on the client.&lt;/strong&gt; See section 4.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Testing it locally
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;stripe login
stripe listen &lt;span class="nt"&gt;--forward-to&lt;/span&gt; localhost:3000/api/stripe/webhook
&lt;span class="c"&gt;# in another terminal&lt;/span&gt;
stripe trigger customer.subscription.updated
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;For a full run-through, use test card &lt;code&gt;4242 4242 4242 4242&lt;/code&gt; in Checkout, check that the database row changes, open the portal, cancel, and check that access goes away when the subscription ends. Replay one event from the Dashboard to confirm the dedupe table catches it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Or skip the wiring with ShipEmber
&lt;/h2&gt;

&lt;p&gt;Full disclosure: I sell a starter kit that already does all of this. &lt;strong&gt;ShipEmber&lt;/strong&gt; is a Next.js App Router + TypeScript starter with Stripe Checkout, the customer portal, and signature-verified, idempotent webhooks (a &lt;code&gt;StripeEvent&lt;/code&gt; table records processed event IDs, as shown above). It also includes a protected dashboard with server-side plan gating (&lt;code&gt;auth()&lt;/code&gt; on the server, no middleware-only checks), Auth.js v5 (Google, GitHub, email magic links), Prisma + Postgres, Tailwind, marketing pages, a one-file rebrand config, tests, and GitHub CI.&lt;/p&gt;

&lt;p&gt;It's a one-time download: $49 for launch week, then $79. You get lifetime v1 updates, can use it in unlimited personal and client projects, and have 14 days to ask for a refund.&lt;/p&gt;

&lt;p&gt;Prefer watching over reading? There's a short walkthrough:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://www.youtube.com/watch?v=telI3nHd9Tg" rel="noopener noreferrer"&gt;Watch the ShipEmber walkthrough on YouTube&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;  &lt;iframe src="https://www.youtube.com/embed/telI3nHd9Tg" width="710" height="399"&gt;
  &lt;/iframe&gt;
&lt;/p&gt;

&lt;p&gt;If you'd rather build it yourself, everything above should get you there. If you'd rather start from working code, &lt;a href="https://shipember.com/pricing.html" rel="noopener noreferrer"&gt;see pricing&lt;/a&gt; or &lt;a href="https://buy.stripe.com/00weVc3I4fgmdBJcie3ks00" rel="noopener noreferrer"&gt;Get ShipEmber, $49&lt;/a&gt;.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Originally published on &lt;a href="https://shipember.com/blog/nextjs-stripe-subscription-starter.html" rel="noopener noreferrer"&gt;shipember.com&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>nextjs</category>
      <category>stripe</category>
      <category>saas</category>
      <category>typescript</category>
    </item>
    <item>
      <title>Why I built ShipEmber (and why it's $49, not $299)</title>
      <dc:creator>Jake Mcdonald</dc:creator>
      <pubDate>Fri, 30 Oct 2026 15:12:03 +0000</pubDate>
      <link>https://dev.to/jakemcdonald/why-i-built-shipember-and-why-its-49-not-299-493e</link>
      <guid>https://dev.to/jakemcdonald/why-i-built-shipember-and-why-its-49-not-299-493e</guid>
      <description>&lt;p&gt;By Jake Mcdonald, founder of ShipEmber Â· October 30, 2026 Â· About an 8-minute read&lt;/p&gt;

&lt;p&gt;I'm Jake Mcdonald. I build software in Springfield, IL, and ShipEmber is the Next.js starter I wish I'd had the last few times I spun up a SaaS.&lt;/p&gt;

&lt;p&gt;This isn't a manifesto. It's the short version of why the kit exists, what's in it, who should buy it, who shouldn't, and why I priced launch week at $49 instead of joining the $199â€“$349 pack.&lt;/p&gt;

&lt;h2&gt;
  
  
  The weeks you don't get back
&lt;/h2&gt;

&lt;p&gt;Every serious side project starts the same way. You open a fresh Next.js app. Then you spend days on things that are not the product:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Auth that works in Server Components and Route Handlers, not only in a client provider.&lt;/li&gt;
&lt;li&gt;Stripe Checkout in &lt;code&gt;subscription&lt;/code&gt; mode, with a customer you reuse instead of creating four of them.&lt;/li&gt;
&lt;li&gt;A webhook that reads the &lt;em&gt;raw&lt;/em&gt; body, verifies the signature, and doesn't double-grant Pro when Stripe retries.&lt;/li&gt;
&lt;li&gt;Plan state in your own database, because the success URL is not proof of payment.&lt;/li&gt;
&lt;li&gt;A dashboard page that actually redirects Free users, on the server.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;None of that is hard once you've done it. The first time â€” or the third time across three abandoned repos â€” it eats a weekend, then another. I kept rewriting the same glue. Eventually I packaged the version I trust and made it downloadable.&lt;/p&gt;

&lt;h2&gt;
  
  
  What ShipEmber includes
&lt;/h2&gt;

&lt;p&gt;One zip. One-time payment. Instant download after checkout. Inside:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Next.js App Router + TypeScript&lt;/strong&gt; â€” the stack most of us already use.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Auth.js v5&lt;/strong&gt; â€” Google, GitHub, and email magic links, with a Prisma adapter.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Stripe billing&lt;/strong&gt; â€” Checkout, the customer portal, and signed, idempotent webhooks that sync status into Postgres.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Prisma + Postgres&lt;/strong&gt; â€” schema and seed so you're not inventing tables at 1 a.m.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A gated dashboard&lt;/strong&gt; â€” Free vs Pro checked where the data lives.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Marketing pages&lt;/strong&gt; â€” landing, pricing, legal stubs, sitemap-friendly structure.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;One-file rebrand&lt;/strong&gt; â€” name, copy, plans without hunting through forty components.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Tests and CI&lt;/strong&gt; â€” so a broken webhook fails in GitHub before it fails for a customer.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If you want the long feature list, it's on the &lt;a href="https://shipember.com/features.html" rel="noopener noreferrer"&gt;features&lt;/a&gt; page. If you want to see it run, watch this:&lt;/p&gt;

&lt;p&gt;  &lt;iframe src="https://www.youtube.com/embed/telI3nHd9Tg" width="710" height="399"&gt;
  &lt;/iframe&gt;
&lt;/p&gt;

&lt;p&gt;&lt;a href="https://www.youtube.com/watch?v=telI3nHd9Tg" rel="noopener noreferrer"&gt;Open the walkthrough on YouTube&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Who it's for
&lt;/h2&gt;

&lt;p&gt;ShipEmber is for indie builders and freelancers who want a paid MVP without spending two weeks on plumbing:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;You know Next.js well enough to customize, not learn it from scratch.&lt;/li&gt;
&lt;li&gt;You need login, Stripe subscriptions, and a Pro-gated page working the same day you buy.&lt;/li&gt;
&lt;li&gt;You want code you can read â€” not a monorepo that takes a day to map.&lt;/li&gt;
&lt;li&gt;You're fine owning the product after download. No subscription to the kit itself.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Who it's not for
&lt;/h2&gt;

&lt;p&gt;I'd rather you don't buy if this isn't the fit:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Multi-tenant B2B on day one.&lt;/strong&gt; Organizations, invites, RBAC, seat billing â€” look at Makerkit or supastarter. ShipEmber is intentionally smaller.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;You want five frameworks and every payment provider.&lt;/strong&gt; I ship one opinionated Next.js path with Stripe.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;You only need a typesafe scaffold.&lt;/strong&gt; Use create-t3-app for free and wire billing yourself (I wrote a &lt;a href="https://shipember.com/blog/nextjs-stripe-subscription-starter.html" rel="noopener noreferrer"&gt;guide for that&lt;/a&gt;).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;You expect me to build your product.&lt;/strong&gt; This is a starter kit, not a studio engagement.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Why choose ShipEmber
&lt;/h2&gt;

&lt;p&gt;Here's the honest pitch â€” the same points I'd tell a friend on a call.&lt;/p&gt;

&lt;h3&gt;
  
  
  1. Low price on purpose
&lt;/h3&gt;

&lt;p&gt;Launch week is &lt;strong&gt;$49&lt;/strong&gt;, then &lt;strong&gt;$79&lt;/strong&gt;. One payment. Compare that to kits we checked recently: ShipFast from &lt;strong&gt;$199&lt;/strong&gt;, Makerkit Pro at &lt;strong&gt;$349&lt;/strong&gt;, supastarter Solo at &lt;strong&gt;$349&lt;/strong&gt; (higher tiers for teams). Those products include more surface area â€” communities, multi-tenancy, agency seats. If you need that, pay for it. If you need auth + Stripe + a dashboard, you shouldn't have to.&lt;/p&gt;

&lt;p&gt;I priced ShipEmber so the decision is easy. If it doesn't work as described, you have 14 days to email me for a refund.&lt;/p&gt;

&lt;h3&gt;
  
  
  2. Minimal, readable code
&lt;/h3&gt;

&lt;p&gt;Big kits earn their price by covering every SaaS edge case. They also take longer to learn. ShipEmber stays small: routes you recognize, a webhook you'd write yourself after reading my Stripe post, plan checks next to the page they protect. You should be able to open the zip and find the billing file in a minute.&lt;/p&gt;

&lt;h3&gt;
  
  
  3. Ships the same day
&lt;/h3&gt;

&lt;p&gt;Pay once. The confirmation page starts the download. Drop in your Stripe, database, and auth keys. Run migrations. Deploy to Vercel. The boring path is already paved so you can spend the evening on the feature that makes the product yours.&lt;/p&gt;

&lt;h3&gt;
  
  
  4. Free to Pro works out of the box
&lt;/h3&gt;

&lt;p&gt;Checkout, webhook sync, and server-side gating are wired together. A Free user hits the upgrade path. A Pro user sees the dashboard. You shouldn't ship a SaaS where "Pro" is a CSS class.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why not $299?
&lt;/h2&gt;

&lt;p&gt;Because the category drifted that way, and I think a lot of first-time founders only need the core loop. Charging $299 for what is essentially "auth, Stripe, dashboard, deploy" would feel like padding. I'd rather sell the kit people actually finish with than the kit that impresses a comparison table.&lt;/p&gt;

&lt;p&gt;That also means I'm not pretending ShipEmber replaces a full B2B platform. Read the "not for" list. Then decide.&lt;/p&gt;

&lt;h2&gt;
  
  
  If this sounds like your stack
&lt;/h2&gt;

&lt;p&gt;Get the zip, add your keys, and build the part that matters.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://buy.stripe.com/00weVc3I4fgmdBJcie3ks00" rel="noopener noreferrer"&gt;Get ShipEmber, $49&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;a href="https://shipember.com/pricing.html" rel="noopener noreferrer"&gt;Pricing details&lt;/a&gt; Â· &lt;a href="https://shipember.com/features.html" rel="noopener noreferrer"&gt;What's inside&lt;/a&gt; Â· &lt;a href="https://shipember.com/blog/best-nextjs-saas-boilerplates.html" rel="noopener noreferrer"&gt;How it compares&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;â€” Jake Mcdonald, Springfield, IL&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Originally published on &lt;a href="https://shipember.com/blog/why-i-built-shipember.html" rel="noopener noreferrer"&gt;shipember.com&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

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