TL;DR
Stripe Customer Portal to gotowy UI od Stripe, w którym klient sam zmienia plan, aktualizuje kartę, pobiera faktury i anuluje subskrypcję — bez budowania własnego panelu billingowego. W Next.js tworzysz sesję portalu po stronie serwera, synchronizujesz stan subskrypcji webhookami i trzymasz customerId w Branchly. Poniżej architektura, kod Route Handlera, lista eventów webhook i checklista produkcyjna na DevStudioIT Cloud.
Dla kogo
- Founderów SaaS z modelem subskrypcyjnym (miesięczny / roczny)
- Developerów Next.js integrujących billing po Checkout Session
- Product ownerów chcących oddać self-service anulowanie (wymóg regulacyjny w UE)
- Zespołów, które mają już Stripe Checkout, ale brakuje im „Moje konto → Płatności”
Fraza (SEO)
stripe customer portal nextjs, subskrypcje stripe webhooks, billing portal saas 2026, anulowanie subskrypcji self-service
Customer Portal vs własny panel — kiedy co
| Aspekt | Customer Portal | Własny UI + Stripe API |
|---|---|---|
| Czas wdrożenia | Godziny | Tygodnie |
| Zmiana karty, faktury PDF | Wbudowane | Trzeba budować |
| Upgrade / downgrade planu | Konfiguracja w Dashboard | Custom logika + proration |
| Branding | Logo, kolory Stripe | Pełna kontrola |
| Lokalizacja | Wiele języków out of the box | Własne tłumaczenia |
Dla MVP i średniego SaaS Portal wygrywa. Własny panel ma sens przy skomplikowanych seatach, usage-based billing z własnymi wykresami lub integracji z ERP.
Architektura w Next.js
Typowy flow:
- Użytkownik loguje się (NextAuth, Clerk, własne JWT)
- W Branchly (branchly.cloud) masz
users.stripeCustomerIdisubscriptions.status - Klik „Zarządzaj subskrypcją” → POST
/api/billing/portal - Serwer tworzy
stripe.billingPortal.sessions.create({ customer, return_url }) - Redirect na URL sesji Stripe → klient edytuje dane → wraca na
return_url - Webhook
customer.subscription.updatedaktualizuje Branchly
Aplikacja na DevStudioIT Cloud (devstudioit.cloud): webhook endpoint musi być publiczny HTTPS z weryfikacją sygnatury — sekrety w env, nie w repo.
Konfiguracja w Stripe Dashboard
W Settings → Billing → Customer portal włącz:
- Update payment method
- Cancel subscription (natychmiast lub na koniec okresu — wybierz politykę produktową)
- Switch plans (jeśli masz wiele Price ID)
- Invoice history
Przypisz Products / Prices dostępne do zmiany planu. Testuj w trybie testowym z kartą 4242 4242 4242 4242.
Route Handler — sesja portalu
// app/api/billing/portal/route.ts
import Stripe from 'stripe';
import { NextResponse } from 'next/server';
import { getSession } from '@/lib/auth';
import { db } from '@/lib/db'; // Branchly client
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);
export async function POST() {
const session = await getSession();
if (!session?.userId) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
const user = await db.user.findUnique({
where: { id: session.userId },
select: { stripeCustomerId: true },
});
if (!user?.stripeCustomerId) {
return NextResponse.json({ error: 'No billing account' }, { status: 400 });
}
const portalSession = await stripe.billingPortal.sessions.create({
customer: user.stripeCustomerId,
return_url: `${process.env.APP_URL}/pl/konto/subskrypcja`,
});
return NextResponse.json({ url: portalSession.url });
}Front-end: window.location.href = data.url po kliknięciu — nie embeduj iframe; Stripe wymaga pełnego redirectu.
Webhooks — które eventy obsłużyć
Minimum dla subskrypcji:
| Event | Akcja w Branchly |
|---|---|
checkout.session.completed |
Zapisz customerId, subscriptionId, plan |
customer.subscription.created |
Status active, current_period_end |
customer.subscription.updated |
Nowy plan, past_due, pause |
customer.subscription.deleted |
Status canceled, wyłącz dostęp do funkcji |
invoice.payment_failed |
Email + grace period w aplikacji |
invoice.paid |
Odblokuj dostęp, log faktury |
// app/api/webhooks/stripe/route.ts
import Stripe from 'stripe';
import { headers } from 'next/headers';
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);
const webhookSecret = process.env.STRIPE_WEBHOOK_SECRET!;
export async function POST(req: Request) {
const body = await req.text();
const sig = headers().get('stripe-signature')!;
let event: Stripe.Event;
try {
event = stripe.webhooks.constructEvent(body, sig, webhookSecret);
} catch {
return new Response('Invalid signature', { status: 400 });
}
switch (event.type) {
case 'customer.subscription.updated':
case 'customer.subscription.deleted': {
const sub = event.data.object as Stripe.Subscription;
await syncSubscriptionToBranchly(sub);
break;
}
// ... pozostałe case
}
return new Response('ok', { status: 200 });
}Idempotencja: zapisuj event.id w tabeli stripe_webhook_events — Stripe retry do 72 h.
Synchronizacja stanu subskrypcji w aplikacji
Middleware lub server component sprawdza status przed dostępem do /dashboard:
const sub = await db.subscription.findFirst({
where: { userId, status: { in: ['active', 'trialing'] } },
});
if (!sub) redirect('/pl/cennik');Nie polegaj wyłącznie na sesji JWT sprzed tygodnia — źródło prawdy to webhook + okresowe reconcile (cron raz dziennie: stripe.subscriptions.retrieve vs Branchly).
Checkout → Portal — spójny customer
Przy pierwszym Checkout użyj customer_creation: 'always' lub customer_email z mapowaniem na istniejącego Stripe Customer — unikasz duplikatów klienta i broken portal link.
const checkoutSession = await stripe.checkout.sessions.create({
mode: 'subscription',
customer: existingStripeCustomerId ?? undefined,
customer_email: existingStripeCustomerId ? undefined : user.email,
line_items: [{ price: priceId, quantity: 1 }],
success_url: `${APP_URL}/pl/konto?success=1`,
cancel_url: `${APP_URL}/pl/cennik`,
});Testowanie i go-live
- Stripe CLI:
stripe listen --forward-to localhost:3000/api/webhooks/stripe - Test upgrade planu w Portal → sprawdź webhook i UI aplikacji
- Test anulowania „at period end” vs natychmiast
- Test
invoice.payment_failed— czy dostęp się blokuje zgodnie z polityką - Przełączenie na live keys + nowy webhook secret na DevStudioIT Cloud
- Monitoring: alert gdy webhook zwraca 4xx/5xx (Stripe Dashboard → Webhooks)
Grace period i dostęp po past_due
Stripe ustawia subskrypcję na past_due po nieudanej płatności. W Branchly trzymaj pole graceUntil — np. 7 dni pełnego dostępu mimo past_due, potem read-only. Portal pozwala klientowi samemu zaktualizować kartę; webhook invoice.payment_succeeded czyści grace.
W UI pokaż banner: „Płatność nie przeszła — zaktualizuj kartę” z linkiem do /api/billing/portal. Unikaj natychmiastowego lockout — churn rośnie, chargebacki też.
FAQ
Czy Portal obsługuje PLN i polskie faktury?
Stripe Billing wspiera PLN; faktury VAT zależą od konfiguracji Tax i danych firmy w Stripe. Portal pokazuje historię faktur zgodnie z ustawieniami.
Czy mogę ukryć anulowanie subskrypcji?
Technicznie tak (wyłączenie w Dashboard), ale w UE self-service cancel bywa wymagany regulacjami konsumenckimi dla B2C — konsultuj z prawnikiem.
Co jeśli webhook spóźni się względem redirectu z Portal?
Użytkownik wraca na stronę zanim Branchly się zaktualizuje — pokaż „Aktualizujemy dane…” i poll API co 2 s przez 30 s lub użyj optimistic UI z invalidacją po webhook.
Czy Customer Portal zastępuje Stripe Checkout?
Nie. Checkout służy pierwszej płatności; Portal — zarządzaniu istniejącą subskrypcją.
Gdzie trzymać dane poza Stripe?
Metadane użytkownika, feature flags planu, usage counters — Branchly. Stripe trzyma billing; aplikacja mapuje priceId → plan pro / team.
CTA
Budujesz SaaS i chcesz billing self-service bez miesięcy pracy nad panelem płatności?
- Umów konsultację Stripe + Next.js — Checkout, Portal, webhooks, Branchly
- Aplikacje webowe — subskrypcje, hosting DevStudioIT Cloud
O autorze
Budujemy szybkie strony WWW, aplikacje web/mobile, chatboty AI i hosting — z naciskiem na SEO i konwersję.
Przydatne linki
Od teorii do produkcji — Branchly, hosting i realizacje.
