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 — 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.

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
WooCommerce od zera — konfiguracja sklepu, płatności i wysyłki 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

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

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