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:
metadata and subscription_data.metadata so every webhook event carries user attribution. Four systems share the work, and each one owns specific data:
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:
1Next.js ──GET /api/box-plans──▶ Strapi (plan data)
2Next.js ──POST /api/checkout──▶ Stripe Checkout (session creation)
3Stripe ──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.
This section produces two Collection Types, one component, and a permissions configuration that the Next.js storefront can query.
1npx 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).
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 with users-permissions.user) box_plan (relation, many-to-one with box-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.
Open Settings > Users & Permissions plugin > Roles in the Admin Panel. For guidance on structuring these correctly, see role-based access control.
find and findOne on box-plan only. Everything else stays unchecked. findOne on subscription. 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.
The Stripe-side IDs become foreign keys in Strapi. This short step bridges the two systems.
Using the Stripe CLI (install with brew install stripe/stripe-cli/stripe and run stripe login):
1stripe products create --name="Basic Box"
2stripe 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.
Use the Stripe API to create a monthly recurring price for each product:
1curl https://api.stripe.com/v1/prices \
2 -u "sk_test_YOUR_KEY:" \
3 -d "product=prod_xxx" \
4 -d unit_amount=2999 \
5 -d currency=usd \
6 -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.
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.
Scaffold the project and install the Stripe dependencies:
1npx create-next-app@latest subscription-storefront --typescript --app
2cd subscription-storefront
3npm install stripe @stripe/stripe-jsAdd these environment variables to .env.local:
1STRAPI_API_URL=http://localhost:1337
2STRIPE_SECRET_KEY=sk_test_...
3NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=pk_test_...
4STRIPE_WEBHOOK_SECRET=whsec_...Variables without the NEXT_PUBLIC_ prefix are never included in the client bundle.
Create a helper function that fetches active box plans from the Strapi REST API:
1// lib/strapi.ts
2export async function getPlans() {
3 const res = await fetch(
4 `${process.env.STRAPI_API_URL}/api/box-plans?filters[is_active][$eq]=true&populate=image`,
5 { cache: 'force-cache', next: { revalidate: 3600 } }
6 );
7 const json = await res.json();
8 return json.data;
9}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:
1{
2 "data": [
3 {
4 "id": 1,
5 "documentId": "abc123def456",
6 "name": "Basic Box",
7 "slug": "basic-box",
8 "stripe_price_id": "price_xxx",
9 "monthly_price": 29.99,
10 "is_active": true
11 }
12 ]
13}The PlansPage Server Component fetches plan data and maps each entry to a PlanCard:
1// app/plans/page.tsx
2import { getPlans } from '@/lib/strapi';
3import PlanCard from '@/components/PlanCard';
4
5export default async function PlansPage() {
6 const plans = await getPlans();
7
8 return (
9 <section>
10 <h1>Choose Your Box</h1>
11 <div className="grid grid-cols-1 md:grid-cols-3 gap-6">
12 {plans.map((plan) => (
13 <PlanCard
14 key={plan.documentId}
15 documentId={plan.documentId}
16 name={plan.name}
17 description={plan.description}
18 monthlyPrice={plan.monthly_price}
19 stripePriceId={plan.stripe_price_id}
20 />
21 ))}
22 </div>
23 </section>
24 );
25}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.
1// components/PlanCard.tsx
2'use client';
3
4interface PlanCardProps {
5 documentId: string;
6 name: string;
7 description: string;
8 monthlyPrice: number;
9 stripePriceId: string;
10}
11
12export default function PlanCard({ documentId, name, description, monthlyPrice, stripePriceId }: PlanCardProps) {
13 const handleSubscribe = async () => {
14 const res = await fetch('/api/checkout', {
15 method: 'POST',
16 headers: { 'Content-Type': 'application/json' },
17 body: JSON.stringify({
18 stripePriceId,
19 boxPlanDocumentId: documentId,
20 userEmail: 'user@example.com', // replace with actual authenticated user email
21 userId: 'current-user-document-id', // replace with actual user documentId
22 }),
23 });
24 const { url } = await res.json();
25 if (url) window.location.href = url;
26 };
27
28 return (
29 <div className="border rounded-lg p-6">
30 <h2 className="text-xl font-bold">{name}</h2>
31 <p className="mt-2 text-gray-600">{description}</p>
32 <p className="mt-4 text-2xl font-semibold">${monthlyPrice}/mo</p>
33 <button
34 onClick={handleSubscribe}
35 className="mt-4 w-full bg-blue-600 text-white py-2 rounded"
36 >
37 Subscribe
38 </button>
39 </div>
40 );
41}In a production app, userEmail and userId come from the authenticated session. The hardcoded values above are placeholders for the checkout flow.
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.
The Route Handler takes the Price ID and Strapi user identifiers, then creates a Checkout session:
1// app/api/checkout/route.ts
2import { NextRequest, NextResponse } from 'next/server';
3import Stripe from 'stripe';
4
5const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);
6
7export async function POST(req: NextRequest) {
8 const { stripePriceId, userEmail, userId, boxPlanDocumentId } = await req.json();
9 const origin = req.headers.get('origin') ?? process.env.NEXT_PUBLIC_APP_URL!;
10
11 const session = await stripe.checkout.sessions.create({
12 mode: 'subscription',
13 line_items: [{ price: stripePriceId, quantity: 1 }],
14 success_url: `${origin}/subscribe/success?session_id={CHECKOUT_SESSION_ID}`,
15 cancel_url: `${origin}/subscribe/cancel`,
16 customer_email: userEmail,
17 metadata: {
18 strapi_user_id: userId,
19 box_plan_document_id: boxPlanDocumentId,
20 },
21 subscription_data: {
22 metadata: {
23 strapi_user_id: userId,
24 box_plan_document_id: boxPlanDocumentId,
25 },
26 },
27 });
28
29 return NextResponse.json({ url: session.url });
30}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.
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.
Build app/subscribe/success/page.tsx as a thin confirmation page:
1// app/subscribe/success/page.tsx
2export default function SubscribeSuccess() {
3 return (
4 <div>
5 <h1>Thanks for subscribing!</h1>
6 <p>Your subscription is being activated. You'll see it on your dashboard shortly.</p>
7 </div>
8 );
9}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.
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.
Create the route file that exposes the Stripe webhook path with auth disabled:
1// src/api/webhook/routes/webhook.ts
2export default {
3 routes: [
4 {
5 method: 'POST',
6 path: '/webhook/stripe',
7 handler: 'api::webhook.webhook.handleStripeWebhook',
8 config: {
9 auth: false,
10 policies: [],
11 },
12 },
13 ],
14};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:
1// config/middlewares.ts
2export default [
3 'strapi::errors',
4 {
5 name: 'strapi::security',
6 config: {
7 contentSecurityPolicy: {
8 useDefaults: true,
9 directives: {
10 'connect-src': ["'self'", 'https:'],
11 upgradeInsecureRequests: null,
12 },
13 },
14 },
15 },
16 'strapi::cors',
17 'strapi::poweredBy',
18 'strapi::logger',
19 'strapi::query',
20 {
21 name: 'strapi::body',
22 config: {
23 includeUnparsed: true,
24 patchKoa: true,
25 multipart: true,
26 },
27 },
28 'strapi::session',
29 'strapi::favicon',
30 'strapi::public',
31];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.
The controller extracts the raw body, verifies the HMAC signature, and returns a 200 before processing:
1// src/api/webhook/controllers/webhook.ts
2import Stripe from 'stripe';
3
4const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);
5
6export default {
7 async handleStripeWebhook(ctx) {
8 const sig = ctx.request.headers['stripe-signature'];
9 let event: Stripe.Event;
10
11 try {
12 const rawBody = ctx.request.body[Symbol.for('unparsedBody')];
13 event = stripe.webhooks.constructEvent(
14 rawBody,
15 sig,
16 process.env.STRIPE_WEBHOOK_SECRET
17 );
18 } catch (err) {
19 strapi.log.error(`Webhook signature verification failed: ${err.message}`);
20 ctx.status = 400;
21 ctx.body = { error: 'Invalid signature' };
22 return;
23 }
24
25 // Return 200 immediately. Stripe retries on non-2xx,
26 // and you don't want retries triggered by a downstream Strapi bug.
27 ctx.status = 200;
28 ctx.body = { received: true };
29
30 // Process events after acknowledgment
31 // (see next subsection for handler implementations)
32 },
33};For local development, forward Stripe events with:
1stripe 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.
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:
1async function handleCheckoutSessionCompleted(session, strapi) {
2 const userId = session.metadata?.strapi_user_id;
3 const boxPlanDocumentId = session.metadata?.box_plan_document_id;
4
5 if (session.mode === 'subscription' && session.payment_status === 'paid') {
6 await strapi.documents('api::subscription.subscription').create({
7 data: {
8 user: userId,
9 box_plan: boxPlanDocumentId,
10 stripe_subscription_id: session.subscription,
11 stripe_customer_id: session.customer,
12 status: 'active',
13 current_period_end: null, // updated by subscription.updated event
14 },
15 });
16 }
17}customer.subscription.updated syncs status and billing period:
1async function handleSubscriptionUpdated(subscription, strapi) {
2 const existing = await strapi.documents('api::subscription.subscription').findFirst({
3 filters: { stripe_subscription_id: subscription.id },
4 });
5 if (!existing) return;
6
7 await strapi.documents('api::subscription.subscription').update({
8 documentId: existing.documentId,
9 data: {
10 status: subscription.status,
11 current_period_end: new Date(subscription.current_period_end * 1000).toISOString(),
12 },
13 });
14}customer.subscription.deleted marks the subscription canceled:
1async function handleSubscriptionDeleted(subscription, strapi) {
2 const existing = await strapi.documents('api::subscription.subscription').findFirst({
3 filters: { stripe_subscription_id: subscription.id },
4 });
5 if (!existing) return;
6
7 await strapi.documents('api::subscription.subscription').update({
8 documentId: existing.documentId,
9 data: { status: 'canceled' },
10 });
11}invoice.payment_failed flags the subscription for dunning:
1async function handleInvoicePaymentFailed(invoice, strapi) {
2 const existing = await strapi.documents('api::subscription.subscription').findFirst({
3 filters: { stripe_subscription_id: invoice.subscription },
4 });
5 if (!existing) return;
6
7 await strapi.documents('api::subscription.subscription').update({
8 documentId: existing.documentId,
9 data: { status: 'past_due' },
10 });
11 // Trigger your dunning email flow here (out of scope, but flag it)
12}The webhook configuration docs cover Strapi-outbound webhooks if you need to notify external services when subscription records change.
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.
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:
1// app/api/auth/login/route.ts
2import { NextRequest, NextResponse } from 'next/server';
3
4export async function POST(req: NextRequest) {
5 const { email, password } = await req.json();
6
7 const strapiRes = await fetch(`${process.env.STRAPI_API_URL}/api/auth/local`, {
8 method: 'POST',
9 headers: { 'Content-Type': 'application/json' },
10 body: JSON.stringify({ identifier: email, password }),
11 });
12
13 const data = await strapiRes.json();
14
15 if (!strapiRes.ok) {
16 return NextResponse.json({ error: data.error?.message ?? 'Login failed' }, { status: 401 });
17 }
18
19 const response = NextResponse.json({ user: data.user });
20 response.cookies.set('jwt', data.jwt, {
21 httpOnly: true,
22 secure: process.env.NODE_ENV === 'production',
23 sameSite: 'lax',
24 path: '/',
25 maxAge: 60 * 60 * 24 * 7, // 7 days
26 });
27
28 return response;
29}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').
The account page fetches the current user and their subscription in a single Server Component:
1// app/account/page.tsx
2import { cookies } from 'next/headers';
3
4export default async function AccountPage() {
5 const cookieStore = await cookies();
6 const jwt = cookieStore.get('jwt')?.value;
7
8 const userRes = await fetch(`${process.env.STRAPI_API_URL}/api/users/me`, {
9 headers: { Authorization: `Bearer ${jwt}` },
10 });
11 const user = await userRes.json();
12
13 const subRes = await fetch(
14 `${process.env.STRAPI_API_URL}/api/subscriptions?filters[user][documentId][$eq]=${user.documentId}&populate=box_plan`,
15 { headers: { Authorization: `Bearer ${jwt}` }, cache: 'no-store' }
16 );
17 const { data: subscriptions } = await subRes.json();
18 const activeSub = subscriptions?.[0];
19
20 return (
21 <div>
22 <h1>Your Subscription</h1>
23 {activeSub ? (
24 <>
25 <p>Plan: {activeSub.box_plan.name}</p>
26 <p>Status: {activeSub.status}</p>
27 <p>Next billing date: {new Date(activeSub.current_period_end).toLocaleDateString()}</p>
28 <ManageBillingButton customerId={activeSub.stripe_customer_id} />
29 </>
30 ) : (
31 <p>No active subscription. <a href="/plans">Browse plans</a></p>
32 )}
33 </div>
34 );
35}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.
The portal route creates a Billing portal session and returns the URL to the client:
1// app/api/portal/route.ts
2import { NextRequest, NextResponse } from 'next/server';
3import Stripe from 'stripe';
4
5const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);
6
7export async function POST(req: NextRequest) {
8 const { customerId } = await req.json();
9
10 const session = await stripe.billingPortal.sessions.create({
11 customer: customerId,
12 return_url: `${process.env.NEXT_PUBLIC_APP_URL}/account`,
13 });
14
15 return NextResponse.json({ url: session.url });
16}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.
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:
strapi.documents()) for creating and updating records in response to Stripe events; 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:
subscription so each cycle can contain different items. subscription update, triggered when status changes to past_due or canceled.For questions or to share what you've built, join the Strapi community forum.
npx create-strapi-app@latest in your terminal and follow our Quick Start Guide to build your first Strapi project.