Przejdź do treści

Stripe w Next.js: Kompletny poradnik integracji

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.

Code
# .env.local
STRIPE_SECRET_KEY=sk_test_xxx
STRIPE_WEBHOOK_SECRET=whsec_xxx
Code
// lib/stripe.ts
import Stripe from 'stripe'
 
export const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!, {
  apiVersion: '2026-02-25.clover',
  typescript: true,
})

Jednorazowa płatność Stripe: Checkout Session

Server Action w Next.js do tworzenia Checkout Session

Code
// actions/checkout.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 createCheckoutSession(productId: string) {
  const session = await auth()
  if (!session?.user) redirect('/login')
 
  const product = await db.product.findUnique({ where: { id: productId } })
  if (!product) throw new Error('Produkt nie istnieje')
 
  const checkoutSession = await stripe.checkout.sessions.create({
    mode: 'payment',
    customer_email: session.user.email!,
    line_items: [
      {
        price_data: {
          currency: 'pln',
          product_data: {
            name: product.name,
            description: product.description || undefined,
            images: product.image ? [product.image] : [],
          },
          unit_amount: Math.round(product.price * 100), // Stripe operuje w groszach
        },
        quantity: 1,
      },
    ],
    metadata: {
      userId: session.user.id,
      productId: product.id,
    },
    success_url: `${process.env.NEXT_PUBLIC_APP_URL}/zamowienie/sukces?session_id={CHECKOUT_SESSION_ID}`,
    cancel_url: `${process.env.NEXT_PUBLIC_APP_URL}/zamowienie/anulowane`,
    // Metody płatności konfiguruj w Stripe Dashboard.
    // Checkout pokaże klientowi tylko metody dostępne dla waluty, kraju i flow.
  })
 
  redirect(checkoutSession.url!)
}

Przycisk kupna kierujący do Stripe Checkout

Code
// components/buy-button.tsx
import { createCheckoutSession } from '@/actions/checkout'
 
export function BuyButton({
  productId,
  price,
}: {
  productId: string
  price: number
}) {
  return (
    <form action={createCheckoutSession.bind(null, productId)}>
      <button
        type="submit"
        className="rounded-lg bg-blue-600 px-6 py-3 text-white hover:bg-blue-700"
      >
        Kup za {price} PLN
      </button>
    </form>
  )
}

Subskrypcje Stripe i recurring billing w Next.js

Definicja planów i cen Stripe

Code
// config/plans.ts
export const PLANS = {
  starter: {
    name: 'Starter',
    stripePriceId: process.env.STRIPE_STARTER_PRICE_ID!,
    price: 49,
    features: ['5 projektów', '1 GB storage', 'Email support'],
  },
  pro: {
    name: 'Pro',
    stripePriceId: process.env.STRIPE_PRO_PRICE_ID!,
    price: 149,
    features: [
      'Unlimited projektów',
      '50 GB storage',
      'Priority support',
      'API',
    ],
  },
} as const
 
export type PlanId = keyof typeof PLANS

Checkout dla subskrypcji Stripe

Code
// 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.tsx
import { 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:

Code
const checkoutSession = await stripe.checkout.sessions.create({
  // ...pozostałe parametry sesji
  automatic_tax: { enabled: true },
  tax_id_collection: { enabled: true },
})

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.ts
import { 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 })
}

Testowanie Stripe webhooks lokalnie

Code
# Stripe CLI — przekierowuje webhooks na localhost
stripe listen --forward-to localhost:3000/api/webhooks/stripe

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.ts
import { 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.
Next.js

Często zadawane pytania

Czy Stripe obsługuje BLIK i Przelewy24?

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 — konsultant technologiczny produktów cyfrowych i web 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 rozwija własne projekty.

Pomagam przekładać takie tematy na konkretne wdrożenia w frontendzie, SEO, analityce i procesie produktowym.

Skontaktuj się ze mną

Biblioteka wiedzy na temat Next.js

Czytaj dalej

Zobacz więcej wpisów
Backend dla frontendowca: auth, real-time i integracje

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

Maciej Sala

Founder StriveLab

WooCommerce od zera i pełna konfiguracja sklepu krok po kroku

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

Maciej Sala

Founder StriveLab

Route Handlers czy Server Actions? Kiedy co wybrać

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.

Maciej Sala

Maciej Sala

Founder StriveLab