Add Stripe Subscriptions to Astro: Recurring Payments Tutorial
Add Stripe recurring subscription billing to your Astro app — API routes, webhooks, and customer portal in one step-by-step tutorial.
You’ve shipped an Astro app and now want to charge for it. Stripe is the obvious choice, but the official docs assume you’re using a heavy framework with a server. Astro is a different beast — it ships static HTML by default, with optional server endpoints. Subscriptions also add complexity: recurring billing, plan changes, cancellations, dunning, and webhook handling all need to work without dropping payments.
Here’s the working pattern for adding Stripe subscriptions to an Astro project, including the parts the docs gloss over.
Prerequisites
Before any code, you need:
- An Astro project running on
output: 'server'oroutput: 'hybrid'(static-only won’t work — you need API routes) - A Stripe account with test mode enabled
- A deployment target that supports Node.js or edge runtime (Vercel, Netlify, Cloudflare, or your own server)
- Two Stripe Products created in the Dashboard, each with a recurring Price (monthly or annual)
- The publishable key, secret key, and webhook signing secret stored as environment variables
The minimum astro.config.mjs for this setup looks like:
import { defineConfig } from 'astro/config';
import vercel from '@astrojs/vercel/serverless';
export default defineConfig({
output: 'hybrid',
adapter: vercel(),
});
Hybrid mode keeps marketing pages static while letting your /api/* routes run on the server. You can also use the Node, Netlify, or Cloudflare adapters depending on where you deploy.
Install the Stripe SDK
npm install stripe @stripe/stripe-js
The first package is the server-side SDK for API calls. The second is the browser SDK used to redirect users to Stripe Checkout. Don’t import the server SDK in any file that gets bundled to the client — it leaks your secret key.
Set up environment variables
Create a .env file at the project root:
STRIPE_SECRET_KEY=sk_test_...
STRIPE_WEBHOOK_SECRET=whsec_...
PUBLIC_STRIPE_PUBLISHABLE_KEY=pk_test_...
PUBLIC_STRIPE_PRICE_MONTHLY=price_...
PUBLIC_STRIPE_PRICE_ANNUAL=price_...
Astro exposes any variable prefixed with PUBLIC_ to the browser. The secret key and webhook secret stay server-side. The price IDs come from the Pricing section of each Product in the Stripe Dashboard.
Create the Stripe client
Centralize Stripe initialization in one file. Create src/lib/stripe.ts:
import Stripe from 'stripe';
export const stripe = new Stripe(import.meta.env.STRIPE_SECRET_KEY, {
apiVersion: '2025-09-30.basil',
typescript: true,
});
Pinning the API version prevents Stripe from breaking your code when they roll out new versions. Update it deliberately, not automatically.
Build the checkout API route
The first thing you need is an endpoint that creates a Stripe Checkout Session for a subscription. Create src/pages/api/checkout.ts:
import type { APIRoute } from 'astro';
import { stripe } from '../../lib/stripe';
export const prerender = false;
export const POST: APIRoute = async ({ request, url }) => {
const { priceId, userId, email } = await request.json();
if (!priceId || !userId) {
return new Response(JSON.stringify({ error: 'Missing fields' }), {
status: 400,
});
}
const session = await stripe.checkout.sessions.create({
mode: 'subscription',
line_items: [{ price: priceId, quantity: 1 }],
customer_email: email,
client_reference_id: userId,
success_url: `${url.origin}/billing/success?session_id={CHECKOUT_SESSION_ID}`,
cancel_url: `${url.origin}/pricing`,
allow_promotion_codes: true,
subscription_data: {
metadata: { userId },
},
});
return new Response(JSON.stringify({ url: session.url }), {
status: 200,
headers: { 'Content-Type': 'application/json' },
});
};
The two important pieces: mode: 'subscription' tells Stripe this is recurring rather than a one-time charge, and client_reference_id plus subscription_data.metadata.userId ties the Stripe subscription back to your user record. You’ll need this when webhooks arrive.
export const prerender = false is required because Astro otherwise tries to prerender every route at build time. API routes need to run on each request.
Wire up the upgrade button
On your pricing page, the upgrade button POSTs to /api/checkout and redirects to the URL Stripe returns. In src/pages/pricing.astro:
---
const monthlyPriceId = import.meta.env.PUBLIC_STRIPE_PRICE_MONTHLY;
const annualPriceId = import.meta.env.PUBLIC_STRIPE_PRICE_ANNUAL;
---
<button
data-price-id={monthlyPriceId}
class="upgrade-btn"
>
Subscribe — $19/mo
</button>
<script>
document.querySelectorAll('.upgrade-btn').forEach((btn) => {
btn.addEventListener('click', async () => {
const priceId = btn.getAttribute('data-price-id');
// In production, get userId/email from your auth provider
const userId = window.userId;
const email = window.userEmail;
const res = await fetch('/api/checkout', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ priceId, userId, email }),
});
const { url } = await res.json();
window.location.href = url;
});
});
</script>
If you already have authentication wired up (this works well alongside the Astro auth setup we covered), pull userId and email from your session instead of window globals.
Handle the webhook
Checkout opens with a redirect. The success URL the user lands on is not where you should grant access — the user could close the tab before redirect, and refunds happen after the fact. Webhooks are the source of truth.
Create src/pages/api/webhook.ts:
import type { APIRoute } from 'astro';
import { stripe } from '../../lib/stripe';
import { grantAccess, revokeAccess } from '../../lib/subscriptions';
export const prerender = false;
export const POST: APIRoute = async ({ request }) => {
const sig = request.headers.get('stripe-signature');
if (!sig) return new Response('No signature', { status: 400 });
const body = await request.text();
const secret = import.meta.env.STRIPE_WEBHOOK_SECRET;
let event;
try {
event = stripe.webhooks.constructEvent(body, sig, secret);
} catch (err) {
return new Response(`Webhook signature failed: ${err.message}`, {
status: 400,
});
}
switch (event.type) {
case 'checkout.session.completed': {
const session = event.data.object;
const userId = session.client_reference_id;
const subscriptionId = session.subscription;
await grantAccess(userId, subscriptionId);
break;
}
case 'customer.subscription.updated': {
const sub = event.data.object;
const userId = sub.metadata.userId;
if (sub.status === 'active' || sub.status === 'trialing') {
await grantAccess(userId, sub.id);
} else {
await revokeAccess(userId);
}
break;
}
case 'customer.subscription.deleted': {
const sub = event.data.object;
await revokeAccess(sub.metadata.userId);
break;
}
case 'invoice.payment_failed': {
// Optional: notify user, downgrade after grace period
break;
}
}
return new Response(JSON.stringify({ received: true }), { status: 200 });
};
The events you actually need to handle:
checkout.session.completed— first-time subscription created. Grant access here.customer.subscription.updated— fires on plan changes, cancellations, reactivations. Re-check status.customer.subscription.deleted— final deletion (happens at end of billing period after cancellation). Revoke access.invoice.payment_failed— card declined. Optional, but useful for dunning logic.
grantAccess and revokeAccess are your own functions that update a user’s subscription state in your database. Whatever you’re using — Postgres, SQLite, KV, Supabase — the logic is the same: store the Stripe subscription ID and current status against the user.
Why webhooks matter: Stripe retries failed webhook deliveries for up to three days. If your endpoint is down briefly, no payment data is lost. If you only granted access via the success-page redirect, a network blip could leave a paid customer locked out.
Verify the webhook signature
The signature check in the snippet above is non-optional. Without it, anyone who knows your endpoint URL could send fake events and grant themselves access. The stripe.webhooks.constructEvent call validates the request was actually signed by Stripe using your shared secret.
For local testing, install the Stripe CLI and forward events to your dev server:
stripe listen --forward-to localhost:4321/api/webhook
This prints a temporary whsec_... value. Use it as STRIPE_WEBHOOK_SECRET while developing locally.
Add the customer portal
Customers will eventually need to update their card, change plans, or cancel. Stripe provides a hosted Customer Portal that handles all of this. You just need a route that creates a session and redirects to it.
Create src/pages/api/portal.ts:
import type { APIRoute } from 'astro';
import { stripe } from '../../lib/stripe';
import { getUserStripeCustomerId } from '../../lib/users';
export const prerender = false;
export const POST: APIRoute = async ({ request, url }) => {
const { userId } = await request.json();
const customerId = await getUserStripeCustomerId(userId);
if (!customerId) {
return new Response('No customer', { status: 404 });
}
const session = await stripe.billingPortal.sessions.create({
customer: customerId,
return_url: `${url.origin}/account`,
});
return new Response(JSON.stringify({ url: session.url }), {
status: 200,
});
};
Add a “Manage subscription” button to your account page that POSTs here and redirects to session.url. Configure the portal’s allowed actions (cancellations, plan changes, payment method updates) in your Stripe Dashboard under Settings → Billing → Customer portal.
Storing customerId against your user record is something you do during the checkout.session.completed webhook. The session payload includes a customer field — save it.
Don’t want to build all this?
If you’re building an MVP and would rather not write webhook handlers, plan-change logic, and a customer portal page, Beag.io gives you authentication and Stripe subscriptions out of the box. Drop in a few components, wire your auth, and you have a billing system that handles signups, upgrades, downgrades, and cancellations. Ship in an evening instead of a weekend.
Common issues
Webhooks aren’t firing. Make sure your webhook endpoint is configured in the Stripe Dashboard for your production environment. Test mode and live mode have separate webhook configurations. Also confirm your deployment platform isn’t caching the route — Astro API routes need prerender = false.
Subscription status flips between active and past_due. This is normal during a card retry cycle. Don’t revoke access on the first invoice.payment_failed. Wait for customer.subscription.deleted or check Stripe’s smart retry results before downgrading.
Users get the success page but no access. The success redirect happens before the webhook arrives in some cases. Don’t grant access on the success page — show a “processing” state and poll your own backend. The webhook will update the user’s record within seconds.
Local webhook testing is broken. The Stripe CLI’s signing secret is different from your production webhook secret. Make sure you’re using the right one in each environment.
FAQ
Do I need a backend to use Stripe with Astro?
Yes. Astro can ship static HTML, but Stripe subscriptions require server-side code for creating checkout sessions and handling webhooks. Use Astro’s hybrid or server output mode with an adapter like Vercel, Netlify, or Node.
Can I use Stripe Payment Links instead of Checkout?
Payment Links work for simple one-off purchases but are a bad fit for subscriptions in production. They don’t give you control over the checkout flow, can’t pass user metadata easily, and you lose the ability to attribute the subscription to a logged-in user. Use full Checkout Sessions for any subscription product where you’ll need to manage upgrades or access control.
How do I handle plan upgrades and downgrades?
The customer portal handles plan changes automatically. When a user upgrades or downgrades, Stripe fires a customer.subscription.updated webhook. Read the new items.data[0].price.id to determine the new plan and update your access logic accordingly.
What happens if a payment fails?
Stripe retries failed payments automatically using its built-in dunning logic. The subscription stays active in past_due status during retries. If retries are exhausted, the subscription transitions to canceled and you’ll get a customer.subscription.deleted event. Don’t revoke access on a single failed payment — wait for the final cancellation.
Should I store the full Stripe subscription object in my database?
Store only what you need: the Stripe customer ID, subscription ID, current status, current period end, and price ID. Re-fetch the full object from Stripe when you need details. This keeps your database simple and avoids stale state issues.
How do I test webhooks locally?
Use the Stripe CLI: stripe listen --forward-to localhost:4321/api/webhook. It opens a tunnel from Stripe’s test mode to your local dev server and provides a temporary signing secret you use during development.
Wrapping up
The full subscription flow comes down to: a checkout endpoint, a webhook endpoint, and a portal endpoint. Astro handles all three through its API routes, and the heavy lifting (recurring billing, retries, plan changes) lives in Stripe’s infrastructure.
If you’d rather skip the wiring and just ship, Beag.io drops auth and Stripe subscriptions into your Astro app in 5 minutes →
Ready to Make Money From Your SaaS?
Turn your SaaS into cash with Beag.io. Get started now!
Start 7-day free trial →