Jak podłączyć Stripe do Next.js App Router: Checkout Session, Customer Portal, webhooks, subskrypcje, zwroty, faktury i testowanie integracji.
Maciej Sala
Founder StriveLab
4 min czytaniaOpublikowano 11 kwietnia 2026 (Aktualizacja 13 lipca 2026)
Dlaczego Stripe w aplikacji Next.js?
Stripe to jedna z najczęściej wybieranych platform płatności w projektach webowych, ze względu na dobre API, dokumentację, obsługę subskrypcji, faktur i . Dla projektów Next.js w Polsce ważne jest też to, że Stripe obsługuje PLN oraz popularne metody płatności, w tym karty, BLIK i Przelewy24 (o ile są dostępne dla Twojego konta i włączone w Dashboardzie).
Setup Stripe w Next.js App Router
Code
npm install stripe
@stripe/stripe-js dodajesz dopiero wtedy, gdy budujesz własny formularz z Elements albo Embedded Checkout. Dla klasycznego Checkout redirect wystarczy SDK serwerowe stripe.
// actions/subscribe.ts'use server'import { stripe } from '@/lib/stripe'import { auth } from '@/auth'import { redirect } from 'next/navigation'import { db } from '@/lib/db'import { PLANS, type PlanId } from '@/config/plans'export async function createSubscription(planId: PlanId) { const session = await auth() if (!session?.user) redirect('/login') const plan = PLANS[planId] const user = await db.user.findUnique({ where: { id: session.user.id } }) // Jeśli klient Stripe już istnieje — reużyj let customerId = user?.stripeCustomerId if (!customerId) { const customer = await stripe.customers.create({ email: session.user.email!, name: session.user.name || undefined, metadata: { userId: session.user.id }, }) customerId = customer.id await db.user.update({ where: { id: session.user.id }, data: { stripeCustomerId: customerId }, }) } const checkoutSession = await stripe.checkout.sessions.create({ mode: 'subscription', customer: customerId, line_items: [{ price: plan.stripePriceId, quantity: 1 }], success_url: `${process.env.NEXT_PUBLIC_APP_URL}/dashboard?upgraded=true`, cancel_url: `${process.env.NEXT_PUBLIC_APP_URL}/cennik`, metadata: { userId: session.user.id, planId }, subscription_data: { metadata: { userId: session.user.id, planId }, }, }) redirect(checkoutSession.url!)}
Stripe Customer Portal do zarządzania subskrypcją
pozwala
klientowi samodzielnie: zmienić plan, zaktualizować kartę, anulować subskrypcję
i pobrać faktury — bez budowania UI.
Code
// actions/billing.ts'use server'import { stripe } from '@/lib/stripe'import { auth } from '@/auth'import { redirect } from 'next/navigation'import { db } from '@/lib/db'export async function openBillingPortal() { const session = await auth() if (!session?.user) redirect('/login') const user = await db.user.findUnique({ where: { id: session.user.id } }) if (!user?.stripeCustomerId) redirect('/cennik') const portalSession = await stripe.billingPortal.sessions.create({ customer: user.stripeCustomerId, return_url: `${process.env.NEXT_PUBLIC_APP_URL}/dashboard`, }) redirect(portalSession.url)}
Code
// components/billing-button.tsximport { openBillingPortal } from '@/actions/billing'export function ManageSubscriptionButton() { return ( <form action={openBillingPortal}> <button className="text-blue-600 hover:underline"> Zarządzaj subskrypcją i fakturami </button> </form> )}
Faktury, VAT i Stripe Tax
Customer Portal pozwala klientowi pobierać dokumenty wystawione przez Stripe,
ale najpierw wymaga właściwej konfiguracji. Przy subskrypcjach Stripe Billing
tworzy faktury automatycznie, przy czym jeśli sprzedajesz w wielu jurysdykcjach, włącz Stripe Tax, zbieraj dane potrzebne do rozliczeń i skonfiguruj rejestracje
podatkowe w Dashboardzie. W Checkout możesz włączyć automatyczne naliczanie podatku oraz pobranie numeru VAT. Parametry włączaj dopiero po skonfigurowaniu odpowiednich rejestracji w Stripe Tax:
W sytuacji kiedy księgowość wymaga dodatkowego obiegu dokumentów lub integracji z polskim
systemem fakturowym, traktuj Stripe tylko i wyłącznie, jako źródło danych o płatnościach.
Stripe webhooks: synchronizacja płatności z bazą danych
Webhooks to serce integracji Stripe. To ono informuje Twoją aplikację o zdarzeniach (płatność zakończona, subskrypcja anulowana, faktura wygenerowana).
Zanim przejdziesz do kodu, musisz przestrzegać dwóch zasad, aby uniknąć błędów w synchronizacji bazy danych. Przede wszystkim Stripe może wysyłać ten sam webhook wielokrotnie, a kolejność zdarzeń nie jest gwarantowana. Z tego względu Twój handler musi być idempotentny, co oznacza stosowanie operacji upsert zamiast prostego create. Ponadto zwracaj status 2xx szybko, po minimalnej koniecznej pracy. Długie przetwarzanie w handlerze prowadzi do timeoutów i lawiny ponownych prób, dlatego wymagające operacje zapisuj do kolejki zadań.
Code
// app/api/webhooks/stripe/route.tsimport { stripe } from '@/lib/stripe'import { headers } from 'next/headers'import { db } from '@/lib/db'import { PLANS } from '@/config/plans'export const runtime = 'nodejs'export async function POST(req: Request) { const body = await req.text() const headersList = await headers() const signature = headersList.get('stripe-signature') if (!signature) { return new Response('Missing signature', { status: 400 }) } let event try { event = stripe.webhooks.constructEvent( body, signature, process.env.STRIPE_WEBHOOK_SECRET!, ) } catch (err) { console.error('Webhook signature verification failed:', err) return new Response('Invalid signature', { status: 400 }) } switch (event.type) { // Dla metod natychmiastowych Checkout kończy się ze statusem "paid". // Dla metod opóźnionych Stripe wyśle później async_payment_succeeded. case 'checkout.session.completed': case 'checkout.session.async_payment_succeeded': { const session = event.data.object if (session.mode === 'payment' && session.payment_status === 'paid') { // Stripe ponawia webhooki — upsert po unikalnym stripeSessionId // zamiast create, żeby retry nie utworzył drugiego zamówienia. await db.order.upsert({ where: { stripeSessionId: session.id }, update: {}, create: { stripeSessionId: session.id, userId: session.metadata?.userId!, productId: session.metadata?.productId!, amount: session.amount_total! / 100, stripePaymentIntentId: session.payment_intent as string, status: 'PAID', }, }) } if (session.mode === 'subscription') { // Nie nadajemy tu dostępu. Pierwsza płatność może wymagać dodatkowego // działania albo nadal być przetwarzana. Dostęp aktualizuje invoice.paid. await db.user.update({ where: { id: session.metadata?.userId }, data: { stripeSubscriptionId: session.subscription as string, }, }) } break } // Opłacona faktura jest właściwym momentem na nadanie lub odnowienie dostępu. case 'invoice.paid': { const invoice = event.data.object // UWAGA: od wersji API Basil (2025) pole invoice.subscription nie // istnieje — powiązanie z subskrypcją żyje w invoice.parent. const subscriptionId = invoice.parent?.subscription_details ?.subscription as string | null if (subscriptionId) { const subscription = await stripe.subscriptions.retrieve(subscriptionId) // Analogicznie okres rozliczeniowy przeniósł się na poziom // subscription items — subscription.current_period_end już nie ma. const periodEnd = subscription.items.data[0]?.current_period_end await db.user.update({ where: { stripeCustomerId: invoice.customer as string }, data: { plan: subscription.metadata?.planId || 'pro', subscriptionStatus: subscription.status, currentPeriodEnd: periodEnd ? new Date(periodEnd * 1000) : null, }, }) } break } // Zwrot wykonany przez aplikację albo ręcznie w Dashboardzie. Status // zamówienia aktualizuje webhook, nie akcja administracyjna. case 'charge.refunded': { const charge = event.data.object if (charge.payment_intent) { await db.order.updateMany({ where: { stripePaymentIntentId: charge.payment_intent as string }, data: { status: charge.refunded ? 'REFUNDED' : 'PARTIALLY_REFUNDED', }, }) } break } // Zmiana planu albo statusu — np. klient przełączył plan w Customer // Portal. Bez tego case'a portal działa, ale baza o niczym nie wie. case 'customer.subscription.created': case 'customer.subscription.updated': { const subscription = event.data.object const priceId = subscription.items.data[0]?.price.id const planEntry = Object.entries(PLANS).find( ([, plan]) => plan.stripePriceId === priceId, ) const periodEnd = subscription.items.data[0]?.current_period_end await db.user.update({ where: { stripeCustomerId: subscription.customer as string }, data: { plan: planEntry?.[0] ?? 'free', subscriptionStatus: subscription.status, stripeSubscriptionId: subscription.id, currentPeriodEnd: periodEnd ? new Date(periodEnd * 1000) : null, }, }) break } // Płatność za fakturę nieudana case 'invoice.payment_failed': { const invoice = event.data.object await db.user.update({ where: { stripeCustomerId: invoice.customer as string }, data: { subscriptionStatus: 'past_due' }, }) // Wyślij email z informacją o problemie z płatnością break } // Subskrypcja anulowana case 'customer.subscription.deleted': { const subscription = event.data.object await db.user.update({ where: { stripeCustomerId: subscription.customer as string }, data: { plan: 'free', subscriptionStatus: 'cancelled', stripeSubscriptionId: null, }, }) break } } return new Response('OK', { status: 200 })}
Stripe CLI wyświetli whsec_xxx — użyj go jako STRIPE_WEBHOOK_SECRET w .env.local.
Zwrot pieniędzy w Stripe i obsługa w Next.js
Przykład zakłada, że model Order ma unikalne pola
stripePaymentIntentId i stripeRefundId oraz statusy REFUND_PENDING,
REFUNDED i PARTIALLY_REFUNDED. Dzięki nim można bezpiecznie rozpoznać
ponowione żądanie i zsynchronizować ręczny zwrot z Dashboardu.
Code
// actions/refund.ts'use server'import { stripe } from '@/lib/stripe'import { auth } from '@/auth'import { db } from '@/lib/db'export async function createRefund(orderId: string) { const session = await auth() if (!session || session.user.role !== 'ADMIN') { return { error: 'Brak uprawnień' } } const order = await db.order.findUnique({ where: { id: orderId } }) if (!order) return { error: 'Zamówienie nie istnieje' } if (order.stripeRefundId) return { success: true } const refund = await stripe.refunds.create( { payment_intent: order.stripePaymentIntentId, reason: 'requested_by_customer', }, // Ten sam klucz sprawia, że retry tej akcji nie utworzy drugiego zwrotu. { idempotencyKey: `refund:${order.id}` }, ) await db.order.update({ where: { id: orderId }, data: { stripeRefundId: refund.id, status: 'REFUND_PENDING', }, }) return { success: true }}
Ta akcja rozpoczyna zwrot, ale nie uznaje go od razu za zakończony. Stripe może
przetwarzać zwrot asynchronicznie, dlatego ostateczny status ustawia
charge.refunded w webhooku. Ten sam handler obsługuje zwroty wykonane ręcznie
w Stripe Dashboard.
Sprawdzanie dostępu po płatności: server-side guard
Code
// lib/check-subscription.tsimport { auth } from '@/auth'import { db } from '@/lib/db'export async function requirePlan(minimumPlan: 'starter' | 'pro') { const session = await auth() if (!session?.user) throw new Error('Unauthorized') const user = await db.user.findUnique({ where: { id: session.user.id } }) const planHierarchy = { free: 0, starter: 1, pro: 2 } const userLevel = planHierarchy[user?.plan as keyof typeof planHierarchy] || 0 const requiredLevel = planHierarchy[minimumPlan] const hasPaidAccess = (user?.subscriptionStatus === 'active' || user?.subscriptionStatus === 'trialing') && !!user.currentPeriodEnd && user.currentPeriodEnd > new Date() if (userLevel < requiredLevel || !hasPaidAccess) { throw new Error(`Wymagany plan: ${minimumPlan}`) } return user}
Elastyczne i wydajne narzędzia dla biznesu, które dotrzymają kroku Twojemu rozwojowi.
Tak, ale najbezpieczniej zarządzać metodami płatności w Stripe Dashboard zamiast wpisywać je na sztywno w kodzie. Checkout pokaże metody pasujące do waluty, kraju klienta, rodzaju płatności i konfiguracji konta.
Ile Stripe pobiera prowizji?
Prowizje zależą od kraju konta, metody płatności, typu transakcji i waluty. Nie wpisuj ich na stałe w ofercie, tylko sprawdzaj aktualny cennik Stripe przed wyceną projektu.
Jak wystawiać faktury VAT?
Stripe Invoicing generuje faktury automatycznie dla subskrypcji. Dla polskiego VAT skonfiguruj Tax Settings w dashboardzie Stripe albo użyj integracji z polskim systemem fakturowym (np. Fakturownia API).
Po co weryfikować podpis webhooka?
Endpoint webhooka jest publiczny, dlatego każdy może wysłać do niego POST. Weryfikacja podpisu przez stripe.webhooks.constructEvent z STRIPE_WEBHOOK_SECRET potwierdza, że żądanie naprawdę pochodzi od Stripe. Bez niej atakujący mógłby sfałszować zdarzenie „płatność zakończona" i odblokować sobie dostęp bez zapłaty.
Dlaczego subscription.current_period_end nie działa?
Od wersji API Basil (marzec 2025) okres rozliczeniowy przeniesiono z poziomu subskrypcji na poziom subscription items. Czytaj go z subscription.items.data[0].current_period_end zamiast z subscription.current_period_end. Podobnie, w wypadku pola invoice.subscription zniknęło, więc powiązanie faktury z subskrypcją jest teraz w invoice.parent.subscription_details. To częste pułapki przy aktualizacji integracji do nowszych wersji API.
O autorze
Maciej Sala
Maciej Sala — Product Manager i Frontend Developer z bogatym doświadczeniem w marketingu internetowym oraz SEO. Na co dzień pracuje z Reactem, Next.js i TypeScriptem, a ostatnio także z Astro i narzędziami do automatyzacji procesów AI. Sprawnie łączy perspektywę produktową z praktycznym podejściem do kodu. Przez kilka lat był związany z branżą gier wideo jako project manager i game designer. Absolwent historii na Uniwersytecie Jagiellońskim oraz studiów podyplomowych z marketingu internetowego na AGH w Krakowie. Po godzinach trenuje na siłowni, maluje figurki i rozwijam własne projekty.
WooCommerce to najczęstsza odpowiedź WordPressa na e-commerce, stworzona specjalnie dla małych i średnich sklepów. Oferuje naprawdę bardzo wiele, ale tylko wtedy, gdy konfiguracja płatności, wysyłki, podatków i całego checkoutu jest zrobiona od początku do końca dokładnie.
Maciej Sala
Founder StriveLab
Pierwsza część serii uporządkowała fundamenty: serwer, bazę danych, API i CORS. Teraz przechodzimy do obszarów, które zwykle pojawiają się chwilę później, gdy aplikacja przestaje być prostym CRUD-em: komunikacja w czasie rzeczywistym, webhooki, integracje zewnętrzne oraz autentykacja .
Maciej Sala
Founder StriveLab
App Router daje Ci dwa sposoby na uruchomienie kodu po stronie serwera, poprzez Route Handlers i Server Actions . Może wyglądają podobnie, ponieważ oba działają na serwerze, sięgają do bazy i do zmiennych środowiskowych, ale każdy z nich rozwiązuje zupełnie inny problem. Użycie jednego tam, gdzie pasuje drugi, nie jest odpowiednim rozwiązaniem, ponieważ zły wybór odbija się potem na architekturze.