<?xml version="1.0" encoding="UTF-8"?><rss xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:content="http://purl.org/rss/1.0/modules/content/" xmlns:atom="http://www.w3.org/2005/Atom" version="2.0"><channel><title><![CDATA[Dipak Chaudhary]]></title><description><![CDATA[Dipak Chaudhary]]></description><link>https://rambo3dev.hashnode.dev</link><generator>RSS for Node</generator><lastBuildDate>Sat, 07 Nov 2026 11:13:08 GMT</lastBuildDate><atom:link href="https://rambo3dev.hashnode.dev/rss.xml" rel="self" type="application/rss+xml"/><language><![CDATA[en]]></language><ttl>60</ttl><item><title><![CDATA[Stripe subscriptions with Supabase and Next.js: the parts tutorials skip]]></title><description><![CDATA[Every SaaS MVP I build needs the same thing sooner or later: users sign up, pick a plan, pay, and the app knows what they're allowed to do. The happy path…]]></description><link>https://rambo3dev.hashnode.dev/stripe-subscriptions-with-supabase-and-next-js-the-parts-tutorials-skip</link><guid isPermaLink="true">https://rambo3dev.hashnode.dev/stripe-subscriptions-with-supabase-and-next-js-the-parts-tutorials-skip</guid><category><![CDATA[Next.js]]></category><category><![CDATA[supabase]]></category><category><![CDATA[stripe]]></category><dc:creator><![CDATA[Dipak Chaudhary]]></dc:creator><pubDate>Sun, 18 Oct 2026 08:06:33 GMT</pubDate><content:encoded><![CDATA[<p>Every SaaS MVP I build needs the same thing sooner or later: users sign up, pick a plan, pay, and the app knows what they're allowed to do. The happy path takes about 20 lines of code. Making it reliable is where the actual work is. Here's the setup I use with Next.js (App Router), Supabase and Stripe, and the mistakes it avoids.</p>
<h2>The core rule: Stripe is the source of truth, webhooks keep you in sync</h2>
<p>Don't mark a user "pro" because they landed on your <code>/success</code> page. People close tabs, cards get declined three days later, and subscriptions renew or cancel without your app being involved. Your database should only change when Stripe tells you something happened, through a webhook.</p>
<h2>1. Tables</h2>
<pre><code class="language-sql">create table public.customers (
  user_id uuid primary key references auth.users on delete cascade,
  stripe_customer_id text unique not null
);

create table public.subscriptions (
  id text primary key,                -- Stripe subscription id
  user_id uuid references auth.users on delete cascade,
  status text not null,               -- active, trialing, past_due, canceled...
  price_id text not null,
  current_period_end timestamptz,
  cancel_at_period_end boolean default false,
  updated_at timestamptz default now()
);

alter table public.customers enable row level security;
alter table public.subscriptions enable row level security;

create policy "read own sub" on public.subscriptions
  for select using (auth.uid() = user_id);
</code></pre>
<p>Notice there are no insert or update policies. Only your webhook, using the service role key on the server, writes to these tables. Users can read their own row, and that's it.</p>
<h2>2. One Stripe customer per user</h2>
<p>Create the customer lazily the first time someone starts checkout, and store the mapping:</p>
<pre><code class="language-ts">async function getOrCreateCustomer(userId: string, email: string) {
  const { data } = await admin
    .from('customers').select('stripe_customer_id')
    .eq('user_id', userId).maybeSingle()
  if (data) return data.stripe_customer_id

  const customer = await stripe.customers.create({
    email,
    metadata: { supabase_user_id: userId },
  })
  await admin.from('customers')
    .insert({ user_id: userId, stripe_customer_id: customer.id })
  return customer.id
}
</code></pre>
<p>Putting <code>supabase_user_id</code> in metadata is cheap insurance: if your mapping table ever gets out of sync, you can rebuild it from Stripe.</p>
<h2>3. Checkout from a server action</h2>
<pre><code class="language-ts">'use server'
export async function startCheckout(priceId: string) {
  const supabase = await createClient()
  const { data: { user } } = await supabase.auth.getUser()
  if (!user) throw new Error('Not signed in')

  const customer = await getOrCreateCustomer(user.id, user.email!)
  const session = await stripe.checkout.sessions.create({
    mode: 'subscription',
    customer,
    line_items: [{ price: priceId, quantity: 1 }],
    success_url: `${process.env.NEXT_PUBLIC_SITE_URL}/billing?ok=1`,
    cancel_url: `${process.env.NEXT_PUBLIC_SITE_URL}/pricing`,
    subscription_data: { metadata: { supabase_user_id: user.id } },
  })
  redirect(session.url!)
}
</code></pre>
<p>Validate <code>priceId</code> against a server-side allowlist. Otherwise anyone can pass in any price from your Stripe account.</p>
<h2>4. The webhook (where most bugs live)</h2>
<pre><code class="language-ts">// app/api/stripe/webhook/route.ts
export async function POST(req: Request) {
  const body = await req.text()                 // raw body, NOT req.json()
  const sig = req.headers.get('stripe-signature')!
  let event: Stripe.Event
  try {
    event = stripe.webhooks.constructEvent(
      body, sig, process.env.STRIPE_WEBHOOK_SECRET!)
  } catch {
    return new Response('Bad signature', { status: 400 })
  }

  switch (event.type) {
    case 'customer.subscription.created':
    case 'customer.subscription.updated':
    case 'customer.subscription.deleted': {
      const sub = event.data.object as Stripe.Subscription
      await upsertSubscription(sub)
      break
    }
  }
  return new Response('ok')
}
</code></pre>
<p>The things that bite people:</p>
<ul>
<li><strong>Parsing the body.</strong> Signature verification needs the raw string. If you call <code>req.json()</code> first, every webhook fails with a signature error.</li>
<li><strong>Wrong secret.</strong> The Stripe CLI (<code>stripe listen</code>) gives you a different <code>whsec_</code> than the dashboard endpoint. Local and production need different values.</li>
<li><strong>Out-of-order events.</strong> Stripe doesn't guarantee order. Rather than trusting the event payload blindly, re-fetch the subscription (<code>stripe.subscriptions.retrieve(sub.id)</code>) and upsert its current state. Then order doesn't matter.</li>
<li><strong>Retries.</strong> Make the handler idempotent: an upsert keyed on the subscription id is safe to run twice.</li>
<li><strong>Slow handlers.</strong> Return 2xx quickly. If you need to send emails or provision things, queue them.</li>
</ul>
<pre><code class="language-ts">async function upsertSubscription(s: Stripe.Subscription) {
  const fresh = await stripe.subscriptions.retrieve(s.id)
  const item = fresh.items.data[0]
  await admin.from('subscriptions').upsert({
    id: fresh.id,
    user_id: fresh.metadata.supabase_user_id,
    status: fresh.status,
    price_id: item.price.id,
    current_period_end: new Date(item.current_period_end * 1000).toISOString(),
    cancel_at_period_end: fresh.cancel_at_period_end,
    updated_at: new Date().toISOString(),
  })
}
</code></pre>
<p>(Depending on your Stripe API version, <code>current_period_end</code> lives on the subscription or on the item. Check which one your version uses.)</p>
<h2>5. Gating features</h2>
<p>Wrap the check in one helper and use it everywhere:</p>
<pre><code class="language-ts">export async function hasActivePlan(userId: string) {
  const { data } = await supabase.from('subscriptions')
    .select('status').eq('user_id', userId)
    .in('status', ['active', 'trialing']).maybeSingle()
  return !!data
}
</code></pre>
<p>For anything sensitive, enforce it in RLS or on the server too, not only by hiding a button.</p>
<h2>6. Let Stripe handle the billing UI</h2>
<p>Don't build "change plan / update card / cancel" screens yourself. The Customer Portal does all of it:</p>
<pre><code class="language-ts">const portal = await stripe.billingPortal.sessions.create({
  customer, return_url: `${siteUrl}/billing`,
})
</code></pre>
<p>Changes made in the portal come back through the same webhook, so your database stays correct without any extra code.</p>
<h2>7. Test before launch</h2>
<ul>
<li><code>stripe listen --forward-to localhost:3000/api/stripe/webhook</code></li>
<li><code>stripe trigger customer.subscription.updated</code></li>
<li>Test cards for declines and 3D Secure (<code>4000 0025 0000 3155</code>).</li>
<li>Use test clocks to simulate a renewal and a failed payment.</li>
<li>Deploy, add the production webhook endpoint in the dashboard, and confirm Vercel's env vars use <strong>live</strong> keys only in Production.</li>
</ul>
<h2>Summary</h2>
<p>Keep Stripe as the source of truth. Let a signed, idempotent webhook write to Supabase with the service role key. Users get read-only access through RLS. And let the Customer Portal do the boring billing screens. Set it up this way once and subscriptions stop being the scary part of launching a SaaS.</p>
<hr />
<p><em>I'm Dipak, a full-stack developer building SaaS MVPs with Next.js, Supabase and Stripe. I'm available for freelance work. You can hire me on <a href="https://www.fiverr.com/usaxelec">Fiverr</a>.</em></p>
<hr />
<p><em>Need help with something similar? Portfolio: <a href="https://cartcord.com">https://cartcord.com</a> | Discord: rambo0671</em></p>
]]></content:encoded></item></channel></rss>