Building a subscription box site means keeping content, billing, and subscription state aligned across multiple systems. This guide uses Strapi 5 as the headless CMS for plan and subscription data, Next.js 16 for the storefront, and Stripe for checkout and recurring billing.
Stripe handles the money, but two systems now own pieces of the same subscription. Stripe knows when the card charges. Strapi knows what goes in the box, who the customer is, and what the shipping address looks like after an update. The job is wiring those two so neither drifts.
This tutorial covers modeling plans in Strapi, handing Stripe the checkout, syncing state back through webhooks, and exposing subscription management to logged-in users. If you're committed to subscription commerce, this is a practical implementation pattern.
In brief:
- Model box plans, subscriptions, and shipping addresses as Strapi 5 Collection Types and components, with Stripe Price IDs stored as foreign keys.
- Create Stripe Checkout sessions from a Next.js Route Handler, setting metadata in both
metadataandsubscription_data.metadataso every webhook event carries user attribution. - Build a Strapi webhook endpoint that verifies the Stripe signature against the raw request body and maps four subscription lifecycle events to Document Service writes.
- Give logged-in users a dashboard that reads their subscription from Strapi and opens the Stripe Billing portal for self-service changes.
Prerequisites and Architecture
Four systems share the work, and each one owns specific data:
- Strapi 5 (latest) owns plan content (descriptions, images, shipping config) and the source-of-truth subscription record per user.
- Stripe owns the payment method, the recurring Price, the charge, and the subscription state machine.
- Next.js 16 (App Router) presents plans, initiates checkout, and renders the user dashboard.
- Webhooks reconcile the two backends. Stripe pushes events; Strapi receives and writes.
You need Node.js v20, v22, or v24 LTS, Stripe test-mode API keys, a fresh Strapi project, and a Next.js project using the App Router.
Here is the data flow:
Next.js ──GET /api/box-plans──▶ Strapi (plan data)
Next.js ──POST /api/checkout──▶ Stripe Checkout (session creation)
Stripe ──POST /api/stripe-webhook──▶ Strapi (subscription events)The finished app lets a visitor browse active box plans, subscribe through Stripe Checkout, and manage their subscription from a logged-in dashboard. A visitor picks a plan, completes payment on Stripe's hosted Checkout page, and lands back on a confirmation screen while a webhook fires in the background to create the subscription record in Strapi.
From the dashboard, the subscriber can view their current plan, check the next billing date, and open the Stripe Billing portal to swap plans, update card details, or cancel. Strapi stays in sync with Stripe through webhooks on every renewal, cancellation, or payment failure.
How to Set Up the Strapi 5 Backend
This section produces two Collection Types, one component, and a permissions configuration that the Next.js storefront can query.
1. Install Strapi 5
npx create-strapi@latest subscription-backendThe --quickstart flag is deprecated in Strapi 5. The interactive CLI prompts for login/signup and project setup questions; pressing Enter accepts defaults and uses SQLite.
SQLite works for local development. For production, use PostgreSQL 14+ (17.0 recommended).
2. Model the Subscription Content-Types
Create two Collection Types and one component through the Content-Type Builder. Each schema below shows the field name, type, and any notable configuration. For a deeper look at structuring these correctly, see content modeling.
box-plan
name(string, required)slug(UID, target field:name)description(rich text)monthly_price(decimal, display only)stripe_price_id(string)image(media, single)is_active(boolean, default:true)
subscription
user(relation, many-to-one withusers-permissions.user)box_plan(relation, many-to-one withbox-plan)stripe_subscription_id(string)stripe_customer_id(string)status(enumeration:incomplete,active,past_due,canceled,trialing,paused)current_period_end(datetime)shipping_address(component, single)
shipping-address (reusable component)
line1(string, required)line2(string)city(string, required)state(string)postal_code(string, required)country(string, required)
The relation between subscription and box-plan is many-to-one because a single plan can have hundreds of active subscribers, but each subscription belongs to exactly one plan.
The shipping_address is modeled as a reusable component rather than a separate Collection Type because it has no independent lifecycle. You never need to query shipping addresses outside the context of a subscription, and embedding the component directly on the subscription record means a single populate=shipping_address returns everything the fulfillment system needs without a join.
The incomplete status on the enumeration covers the period before a subscription's first invoice is successfully paid; Stripe reports that state as incomplete on the subscription object.
One thing to note: Strapi 5 returns a flat response shape where fields live directly on each entry alongside documentId. There is no .attributes wrapper. All Next.js fetches in later sections reference documentId, not the numeric id. The Document Service API docs cover this in detail.
3. Configure Roles and Permissions
Open Settings > Users & Permissions plugin > Roles in the Admin Panel. For guidance on structuring these correctly, see role-based access control.
- Public role: Enable
findandfindOneonbox-planonly. Everything else stays unchecked. - Authenticated role: Enable
findOneonsubscription. You'll add a controller-level ownership check (covered later) so users can only read their own records.
The webhook endpoint is a custom route that skips standard auth and instead relies on signature verification. It relies on Stripe signature verification rather than Strapi auth, which is configured with config: { auth: false } on the route definition.
How to Create Products and Prices in Stripe
The Stripe-side IDs become foreign keys in Strapi. This short step bridges the two systems.
1. Create One Product per Box Tier
Using the Stripe CLI (install with brew install stripe/stripe-cli/stripe and run stripe login):
stripe products create --name="Basic Box"
stripe products create --name="Premium Box"Each returns a prod_xxx ID. Keep product names matching box-plan.name for easy cross-referencing in the dashboard.
You can also create products through the Stripe Dashboard under Product Catalog if you prefer a UI.
2. Attach a Recurring Price to Each Product
Use the Stripe API to create a monthly recurring price for each product:
curl https://api.stripe.com/v1/prices \
-u "sk_test_YOUR_KEY:" \
-d "product=prod_xxx" \
-d unit_amount=2999 \
-d currency=usd \
-d "recurring[interval]=month"This creates a monthly recurring price of $29.99 (amounts are in cents). The response includes a price_xxx ID. Copy that ID into the matching box-plan.stripe_price_id field in the Strapi Admin Panel.
You store the Price ID and not the Product ID because Checkout consumes Prices directly. The Price encodes the billing interval, currency, and amount that Checkout needs to process a charge. See Stripe's price object documentation for the full parameter reference.
How to Build the Next.js 16 Storefront
The storefront needs three things: a plan list page, a checkout button on each plan, and success and cancel routes to land on after Stripe.
1. Bootstrap the Next.js Project
Scaffold the project and install the Stripe dependencies:
npx create-next-app@latest subscription-storefront --typescript --app
cd subscription-storefront
npm install stripe @stripe/stripe-jsAdd these environment variables to .env.local:
STRAPI_API_URL=http://localhost:1337
STRIPE_SECRET_KEY=sk_test_...
NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=pk_test_...
STRIPE_WEBHOOK_SECRET=whsec_...Variables without the NEXT_PUBLIC_ prefix are never included in the client bundle.
2. Fetch Plans from Strapi as a Server Component
Create a helper function that fetches active box plans from the Strapi REST API:
// lib/strapi.ts
export async function getPlans() {
const res = await fetch(
`${process.env.STRAPI_API_URL}/api/box-plans?filters[is_active][$eq]=true&populate=image`,
{ cache: 'force-cache', next: { revalidate: 3600 } }
);
const json = await res.json();
return json.data;
}Next.js 15 reversed the caching defaults used in Next.js 14, and Next.js 16 keeps that behavior. Fetches are no longer cached by default, so the cache: 'force-cache' and next: { revalidate: 3600 } options are explicit and necessary here.
Without explicit caching options, dynamic page requests will hit Strapi, though statically cached routes may not on every page load. For other performance traps in this stack, see performance mistakes.
The Strapi 5 response shape is flat. Fields sit directly on each entry:
{
"data": [
{
"id": 1,
"documentId": "abc123def456",
"name": "Basic Box",
"slug": "basic-box",
"stripe_price_id": "price_xxx",
"monthly_price": 29.99,
"is_active": true
}
]
}3. Render the Plan Selection UI
The PlansPage Server Component fetches plan data and maps each entry to a PlanCard:
// app/plans/page.tsx
import { getPlans } from '@/lib/strapi';
import PlanCard from '@/components/PlanCard';
export default async function PlansPage() {
const plans = await getPlans();
return (
<section>
<h1>Choose Your Box</h1>
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
{plans.map((plan) => (
<PlanCard
key={plan.documentId}
documentId={plan.documentId}
name={plan.name}
description={plan.description}
monthlyPrice={plan.monthly_price}
stripePriceId={plan.stripe_price_id}
/>
))}
</div>
</section>
);
}PlansPage is a Server Component: it runs on the server at build or request time, fetches plan data from Strapi, and sends rendered HTML to the browser. PlanCard, on the other hand, must be a Client Component because it attaches an onClick handler to the Subscribe button. Event handlers require client-side JavaScript, so the 'use client' directive marks that boundary.
The server component handles data fetching; the client component handles interactivity. This split keeps the Strapi API token and fetch logic off the client while still giving users a clickable checkout button.
// components/PlanCard.tsx
'use client';
interface PlanCardProps {
documentId: string;
name: string;
description: string;
monthlyPrice: number;
stripePriceId: string;
}
export default function PlanCard({ documentId, name, description, monthlyPrice, stripePriceId }: PlanCardProps) {
const handleSubscribe = async () => {
const res = await fetch('/api/checkout', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
stripePriceId,
boxPlanDocumentId: documentId,
userEmail: 'user@example.com', // replace with actual authenticated user email
userId: 'current-user-document-id', // replace with actual user documentId
}),
});
const { url } = await res.json();
if (url) window.location.href = url;
};
return (
<div className="border rounded-lg p-6">
<h2 className="text-xl font-bold">{name}</h2>
<p className="mt-2 text-gray-600">{description}</p>
<p className="mt-4 text-2xl font-semibold">${monthlyPrice}/mo</p>
<button
onClick={handleSubscribe}
className="mt-4 w-full bg-blue-600 text-white py-2 rounded"
>
Subscribe
</button>
</div>
);
}In a production app, userEmail and userId come from the authenticated session. The hardcoded values above are placeholders for the checkout flow.
How to Launch Stripe Checkout for Subscriptions
Hosted Checkout can significantly reduce PCI scope and can support 3-D Secure, trials, taxes, and coupons with little to no custom payment UI work. The route handler creates the session with the metadata the webhook needs to stitch it back to Strapi. For a broader overview of payment gateways, this guide compares the options.
1. Create the Checkout Session Route
The Route Handler takes the Price ID and Strapi user identifiers, then creates a Checkout session:
// app/api/checkout/route.ts
import { NextRequest, NextResponse } from 'next/server';
import Stripe from 'stripe';
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);
export async function POST(req: NextRequest) {
const { stripePriceId, userEmail, userId, boxPlanDocumentId } = await req.json();
const origin = req.headers.get('origin') ?? process.env.NEXT_PUBLIC_APP_URL!;
const session = await stripe.checkout.sessions.create({
mode: 'subscription',
line_items: [{ price: stripePriceId, quantity: 1 }],
success_url: `${origin}/subscribe/success?session_id={CHECKOUT_SESSION_ID}`,
cancel_url: `${origin}/subscribe/cancel`,
customer_email: userEmail,
metadata: {
strapi_user_id: userId,
box_plan_document_id: boxPlanDocumentId,
},
subscription_data: {
metadata: {
strapi_user_id: userId,
box_plan_document_id: boxPlanDocumentId,
},
},
});
return NextResponse.json({ url: session.url });
}The line_items[].price field takes a Price ID (price_xxx), not a Product ID. See the Checkout session documentation for the full parameter list.
2. Attach Strapi Identifiers to the Session
Both metadata and subscription_data.metadata matter because they live on separate Stripe objects and do not propagate to each other. Session-level metadata appears on the checkout.session.completed event.
Later events like customer.subscription.updated include metadata from the Subscription object (which, when created via Checkout, comes from subscription_data.metadata), while invoice.payment_failed includes an Invoice object that can contain a snapshot of the subscription's metadata and other invoice-related metadata.
If you skip subscription_data.metadata, your webhook handler has no clean way to attribute renewals, cancellations, or plan changes to a Strapi user. This is where the two systems get bound. If the subscription record never appears in Strapi after a successful checkout, the most likely cause is that one or both metadata blocks were omitted from the session creation call.
3. Handle the Redirect and Success States
Build app/subscribe/success/page.tsx as a thin confirmation page:
// app/subscribe/success/page.tsx
export default function SubscribeSuccess() {
return (
<div>
<h1>Thanks for subscribing!</h1>
<p>Your subscription is being activated. You'll see it on your dashboard shortly.</p>
</div>
);
}Do not provision the subscription on the success page. A user can reach this URL without payment completing (back button, direct navigation). Wait for the webhook. The app/subscribe/cancel/page.tsx page is similar, with copy indicating they can try again.
How to Sync Stripe Subscriptions to Strapi with Webhooks
The webhook handler lives inside Strapi, not Next.js. Strapi owns the subscription record, so the side that owns the data should own the writes. Next.js never writes subscription records directly. For more on building a custom API endpoint in Strapi, that guide covers the general pattern.
1. Register the Webhook Endpoint in Strapi
Create the route file that exposes the Stripe webhook path with auth disabled:
// src/api/webhook/routes/webhook.ts
export default {
routes: [
{
method: 'POST',
path: '/webhook/stripe',
handler: 'api::webhook.webhook.handleStripeWebhook',
config: {
auth: false,
policies: [],
},
},
],
};The route config object accepts auth, policies, and middlewares. Stripe's constructEvent() requires the unparsed bytes for Hash-based Message Authentication Code (HMAC) signature verification, but Strapi's body middleware (koa-body) parses the body before your controller runs. Configure config/middlewares.ts to preserve the raw body:
// config/middlewares.ts
export default [
'strapi::errors',
{
name: 'strapi::security',
config: {
contentSecurityPolicy: {
useDefaults: true,
directives: {
'connect-src': ["'self'", 'https:'],
upgradeInsecureRequests: null,
},
},
},
},
'strapi::cors',
'strapi::poweredBy',
'strapi::logger',
'strapi::query',
{
name: 'strapi::body',
config: {
includeUnparsed: true,
patchKoa: true,
multipart: true,
},
},
'strapi::session',
'strapi::favicon',
'strapi::public',
];Using the wrong webhook endpoint secret and passing a parsed or otherwise modified request body are two common causes of Stripe signature verification failures. Stripe requires the raw request body for verification. This article's Strapi 5 includeUnparsed: true example may work, but validate it against current Strapi middleware behavior or official Strapi documentation before treating it as a confirmed requirement.
2. Verify the Stripe Signature
The controller extracts the raw body, verifies the HMAC signature, and returns a 200 before processing:
// src/api/webhook/controllers/webhook.ts
import Stripe from 'stripe';
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);
export default {
async handleStripeWebhook(ctx) {
const sig = ctx.request.headers['stripe-signature'];
let event: Stripe.Event;
try {
const rawBody = ctx.request.body[Symbol.for('unparsedBody')];
event = stripe.webhooks.constructEvent(
rawBody,
sig,
process.env.STRIPE_WEBHOOK_SECRET
);
} catch (err) {
strapi.log.error(`Webhook signature verification failed: ${err.message}`);
ctx.status = 400;
ctx.body = { error: 'Invalid signature' };
return;
}
// Return 200 immediately. Stripe retries on non-2xx,
// and you don't want retries triggered by a downstream Strapi bug.
ctx.status = 200;
ctx.body = { received: true };
// Process events after acknowledgment
// (see next subsection for handler implementations)
},
};For local development, forward Stripe events with:
stripe listen --forward-to http://localhost:1337/api/webhook/stripeThis outputs a temporary whsec_... signing secret. Use it as STRIPE_WEBHOOK_SECRET locally. To verify end-to-end, run stripe trigger checkout.session.completed in a second terminal. A 200 response in the CLI output indicates that your endpoint returned a successful HTTP status code, but it does not by itself confirm that signature verification passed. Check the Subscription collection in the Strapi Admin Panel for the new record.
3. Map Webhook Events to Subscription Records
Several webhook events matter for this app. Note that Strapi 5 uses the Document Service API (strapi.documents()); the Entity Service is deprecated. Filter on stripe_subscription_id for updates.
For context on how Strapi webhooks work outbound, that guide covers Strapi-initiated webhooks (distinct from this Stripe-inbound pattern). You can also trigger downstream actions using lifecycle hooks on the subscription Content-Type.
checkout.session.completed creates the subscription record:
async function handleCheckoutSessionCompleted(session, strapi) {
const userId = session.metadata?.strapi_user_id;
const boxPlanDocumentId = session.metadata?.box_plan_document_id;
if (session.mode === 'subscription' && session.payment_status === 'paid') {
await strapi.documents('api::subscription.subscription').create({
data: {
user: userId,
box_plan: boxPlanDocumentId,
stripe_subscription_id: session.subscription,
stripe_customer_id: session.customer,
status: 'active',
current_period_end: null, // updated by subscription.updated event
},
});
}
}customer.subscription.updated syncs status and billing period:
async function handleSubscriptionUpdated(subscription, strapi) {
const existing = await strapi.documents('api::subscription.subscription').findFirst({
filters: { stripe_subscription_id: subscription.id },
});
if (!existing) return;
await strapi.documents('api::subscription.subscription').update({
documentId: existing.documentId,
data: {
status: subscription.status,
current_period_end: new Date(subscription.current_period_end * 1000).toISOString(),
},
});
}customer.subscription.deleted marks the subscription canceled:
async function handleSubscriptionDeleted(subscription, strapi) {
const existing = await strapi.documents('api::subscription.subscription').findFirst({
filters: { stripe_subscription_id: subscription.id },
});
if (!existing) return;
await strapi.documents('api::subscription.subscription').update({
documentId: existing.documentId,
data: { status: 'canceled' },
});
}invoice.payment_failed flags the subscription for dunning:
async function handleInvoicePaymentFailed(invoice, strapi) {
const existing = await strapi.documents('api::subscription.subscription').findFirst({
filters: { stripe_subscription_id: invoice.subscription },
});
if (!existing) return;
await strapi.documents('api::subscription.subscription').update({
documentId: existing.documentId,
data: { status: 'past_due' },
});
// Trigger your dunning email flow here (out of scope, but flag it)
}The webhook configuration docs cover Strapi-outbound webhooks if you need to notify external services when subscription records change.
How to Expose Subscription Management to Logged-In Users
Users need a way to log in, see their active plan, and open the Stripe Billing portal to swap plans, update card details, or cancel.
1. Authenticate Users Against Strapi
Use Strapi's users-permissions plugin with email/password. For a detailed walkthrough, see authentication and authorization in Strapi and Next.js auth guide for the Next.js integration.
Store the JSON Web Token (JWT) in an httpOnly cookie via a Next.js Route Handler. This keeps the token out of localStorage, where it would be vulnerable to XSS. Because the cookie is httpOnly, client-side JavaScript cannot read it, but Server Components and Route Handlers can access it through (await cookies()).get('jwt') — cookies() is asynchronous in Next.js 16.
That means every server-side fetch to Strapi can include the token in the Authorization header without the token ever appearing in the browser's JavaScript runtime. Auth.js (NextAuth) is also an option, but it adds a second identity layer to keep in sync with Strapi's user records.
Here is the login proxy route that sets the cookie:
// app/api/auth/login/route.ts
import { NextRequest, NextResponse } from 'next/server';
export async function POST(req: NextRequest) {
const { email, password } = await req.json();
const strapiRes = await fetch(`${process.env.STRAPI_API_URL}/api/auth/local`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ identifier: email, password }),
});
const data = await strapiRes.json();
if (!strapiRes.ok) {
return NextResponse.json({ error: data.error?.message ?? 'Login failed' }, { status: 401 });
}
const response = NextResponse.json({ user: data.user });
response.cookies.set('jwt', data.jwt, {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'lax',
path: '/',
maxAge: 60 * 60 * 24 * 7, // 7 days
});
return response;
}The client-side login form posts to /api/auth/login. On success, the JWT is stored as a cookie automatically, and subsequent server-side fetches read it with (await cookies()).get('jwt').
2. Read the Active Subscription on the Dashboard
The account page fetches the current user and their subscription in a single Server Component:
// app/account/page.tsx
import { cookies } from 'next/headers';
export default async function AccountPage() {
const cookieStore = await cookies();
const jwt = cookieStore.get('jwt')?.value;
const userRes = await fetch(`${process.env.STRAPI_API_URL}/api/users/me`, {
headers: { Authorization: `Bearer ${jwt}` },
});
const user = await userRes.json();
const subRes = await fetch(
`${process.env.STRAPI_API_URL}/api/subscriptions?filters[user][documentId][$eq]=${user.documentId}&populate=box_plan`,
{ headers: { Authorization: `Bearer ${jwt}` }, cache: 'no-store' }
);
const { data: subscriptions } = await subRes.json();
const activeSub = subscriptions?.[0];
return (
<div>
<h1>Your Subscription</h1>
{activeSub ? (
<>
<p>Plan: {activeSub.box_plan.name}</p>
<p>Status: {activeSub.status}</p>
<p>Next billing date: {new Date(activeSub.current_period_end).toLocaleDateString()}</p>
<ManageBillingButton customerId={activeSub.stripe_customer_id} />
</>
) : (
<p>No active subscription. <a href="/plans">Browse plans</a></p>
)}
</div>
);
}Note that the filter query should use documentId rather than the numeric id, since Strapi 5 uses documentId as the primary identifier for API calls.
3. Open the Stripe Billing Portal
The portal route creates a Billing portal session and returns the URL to the client:
// app/api/portal/route.ts
import { NextRequest, NextResponse } from 'next/server';
import Stripe from 'stripe';
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);
export async function POST(req: NextRequest) {
const { customerId } = await req.json();
const session = await stripe.billingPortal.sessions.create({
customer: customerId,
return_url: `${process.env.NEXT_PUBLIC_APP_URL}/account`,
});
return NextResponse.json({ url: session.url });
}Activate the Customer Portal in the Stripe Dashboard under Settings > Billing > Customer Portal first. Enable the features you need: plan switching, card updates, cancellation.
When the user makes a change in the portal, your webhook handler from the previous section catches it. Portal events include customer.subscription.updated for plan changes and customer.subscription.deleted for cancellations, plus customer.subscription.updated with cancel_at_period_end: true for scheduled cancellations before the final deletion event. Keeping the Strapi record in sync requires additional synchronization logic.
What This Architecture Gets You
You've built a subscription box site where Strapi owns content and customer records, Stripe owns billing, and webhooks keep both sides consistent. Each system does what it's best at: Strapi gives your content team a visual interface for plans and shipping configuration, while Stripe handles payment complexity you'd never want to build yourself. The webhook bridge means changes propagate automatically.
Strapi's specific contributions to this architecture:
- Flexible content modeling defined plans, subscriptions, and shipping addresses as Collection Types and components without handwritten schema files;
- Role-based permissions restricted plan data to public access while protecting subscription records behind authentication;
- Document Service writes gave the webhook handler a clean API (
strapi.documents()) for creating and updating records in response to Stripe events; - Strapi Cloud deployment provides a managed hosting path so the same models and routes run in production without infrastructure overhead.
Deploy and What to Add Next
Deploy: Run Strapi on Strapi Cloud or any Node host with PostgreSQL. Deploy Next.js on Vercel or a comparable platform. Point the Stripe webhook endpoint to your production Strapi domain under Developers > Webhooks in the Dashboard.
Use separate STRIPE_WEBHOOK_SECRET and STRIPE_SECRET_KEY values per environment — the whsec_... from stripe listen is a temporary local secret, and test-mode API keys differ from live-mode keys (sk_live_...).
What to add next:
- Per-cycle box variants. Model the upcoming box as a relation on
subscriptionso each cycle can contain different items. - Pause and resume via the Stripe API for tighter control than the Billing portal alone.
- Customer notifications via a Document Service middleware on
subscriptionupdate, triggered whenstatuschanges topast_dueorcanceled.
For questions or to share what you've built, join the Strapi community forum.
Get Started in Minutes
npx create-strapi-app@latest in your terminal and follow our Quick Start Guide to build your first Strapi project.