Integracja płatności Stripe w aplikacji Next.js
Integracja płatności Stripe
poradnikiIntegracja płatności Stripe w Next.js
Obsługa płatności online to kluczowy element każdego sklepu internetowego i aplikacji SaaS. Stripe od lat jest jednym z najpopularniejszych procesorów płatności na świecie, oferując rozbudowane API, doskonałą dokumentację i szerokie wsparcie dla nowoczesnych frameworków. W tym przewodniku pokażemy, jak krok po kroku zintegrować Stripe z aplikacją Next.js, wykorzystując TypeScript, API Routes, webhooki, subskrypcje i wiele innych funkcji.
Czym jest Stripe?#
Stripe to platforma do obsługi płatności internetowych, z której korzystają zarówno startupy, jak i globalne korporacje (m.in. Shopify, Amazon, Google). Stripe oferuje:
- Przetwarzanie płatności kartami kredytowymi i debetowymi w ponad 135 walutach
- Stripe Elements — gotowe, konfigurowalne komponenty UI do bezpiecznego zbierania danych kart
- Checkout Sessions — hostowane strony płatności z pełnym UI
- Payment Intents API — elastyczne API do budowania własnych przepływów płatności
- Billing — zarządzanie subskrypcjami i fakturami
- Stripe Dashboard — panel administracyjny do monitorowania transakcji, zwrotów i analityki
- Stripe CLI — narzędzie do testowania webhooków lokalnie
Stripe jest zgodny ze standardami PCI DSS Level 1, co oznacza, że dane kart płatniczych nigdy nie przechodzą przez Twój serwer, jeśli korzystasz z Elements lub Checkout.
Konfiguracja projektu#
Zacznijmy od zainstalowania wymaganych pakietów w projekcie Next.js:
// Instalacja zależności
// npm install stripe @stripe/stripe-js @stripe/react-stripe-js
// Zmienne środowiskowe (.env.local)
// NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=pk_test_...
// STRIPE_SECRET_KEY=sk_test_...
// STRIPE_WEBHOOK_SECRET=whsec_...
Następnie skonfigurujmy instancję Stripe po stronie serwera i klienta:
// lib/stripe.ts
import Stripe from 'stripe';
export const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!, {
apiVersion: '2023-10-16',
typescript: true,
});
// lib/stripe-client.ts
import { loadStripe, Stripe } from '@stripe/stripe-js';
let stripePromise: Promise<Stripe | null>;
export const getStripe = () => {
if (!stripePromise) {
stripePromise = loadStripe(
process.env.NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY!
);
}
return stripePromise;
};
Stripe Checkout Sessions#
Najprostszym sposobem na przyjęcie płatności jest Stripe Checkout — hostowana strona płatności, którą Stripe w pełni zarządza. Nie musisz budować własnego formularza ani martwić się o PCI compliance.
API Route do tworzenia sesji#
// app/api/checkout/route.ts
import { NextRequest, NextResponse } from 'next/server';
import { stripe } from '@/lib/stripe';
interface CheckoutRequestBody {
priceId: string;
quantity?: number;
mode?: 'payment' | 'subscription';
}
export async function POST(request: NextRequest) {
try {
const body: CheckoutRequestBody = await request.json();
const { priceId, quantity = 1, mode = 'payment' } = body;
if (!priceId) {
return NextResponse.json(
{ error: 'Brak wymaganego parametru priceId' },
{ status: 400 }
);
}
const session = await stripe.checkout.sessions.create({
mode,
payment_method_types: ['card', 'blik', 'p24'],
line_items: [
{
price: priceId,
quantity,
},
],
success_url: `${process.env.NEXT_PUBLIC_APP_URL}/checkout/success?session_id={CHECKOUT_SESSION_ID}`,
cancel_url: `${process.env.NEXT_PUBLIC_APP_URL}/checkout/cancel`,
metadata: {
source: 'next-app',
},
});
return NextResponse.json({ sessionId: session.id, url: session.url });
} catch (error) {
console.error('Błąd tworzenia sesji Checkout:', error);
return NextResponse.json(
{ error: 'Nie udało się utworzyć sesji płatności' },
{ status: 500 }
);
}
}
Komponent przekierowujący do Checkout#
// components/CheckoutButton.tsx
'use client';
import { useState } from 'react';
import { getStripe } from '@/lib/stripe-client';
interface CheckoutButtonProps {
priceId: string;
mode?: 'payment' | 'subscription';
label?: string;
}
export function CheckoutButton({
priceId,
mode = 'payment',
label = 'Kup teraz',
}: CheckoutButtonProps) {
const [loading, setLoading] = useState(false);
const handleCheckout = async () => {
setLoading(true);
try {
const response = await fetch('/api/checkout', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ priceId, mode }),
});
const { sessionId } = await response.json();
const stripe = await getStripe();
if (stripe) {
const { error } = await stripe.redirectToCheckout({ sessionId });
if (error) {
console.error('Błąd przekierowania:', error.message);
}
}
} catch (error) {
console.error('Błąd Checkout:', error);
} finally {
setLoading(false);
}
};
return (
<button
onClick={handleCheckout}
disabled={loading}
className="bg-indigo-600 text-white px-6 py-3 rounded-lg
hover:bg-indigo-700 disabled:opacity-50 transition-colors"
>
{loading ? 'Przetwarzanie...' : label}
</button>
);
}
Payment Intents — zaawansowane przepływy#
Jeśli potrzebujesz pełnej kontroli nad wyglądem formularza płatności, użyj Payment Intents API w połączeniu ze Stripe Elements. Dzięki temu budujesz własny UI, a Stripe zajmuje się bezpiecznym przetwarzaniem danych karty.
Tworzenie Payment Intent po stronie serwera#
// app/api/payment-intent/route.ts
import { NextRequest, NextResponse } from 'next/server';
import { stripe } from '@/lib/stripe';
interface PaymentIntentRequest {
amount: number;
currency?: string;
description?: string;
customerEmail?: string;
}
export async function POST(request: NextRequest) {
try {
const body: PaymentIntentRequest = await request.json();
const { amount, currency = 'pln', description, customerEmail } = body;
// Walidacja po stronie serwera
if (!amount || amount < 200) {
return NextResponse.json(
{ error: 'Minimalna kwota to 2.00 PLN' },
{ status: 400 }
);
}
if (amount > 99999999) {
return NextResponse.json(
{ error: 'Kwota przekracza dozwolony limit' },
{ status: 400 }
);
}
// Opcjonalnie: utwórz lub pobierz klienta Stripe
let customerId: string | undefined;
if (customerEmail) {
const customers = await stripe.customers.list({
email: customerEmail,
limit: 1,
});
if (customers.data.length > 0) {
customerId = customers.data[0].id;
} else {
const customer = await stripe.customers.create({
email: customerEmail,
});
customerId = customer.id;
}
}
const paymentIntent = await stripe.paymentIntents.create({
amount,
currency,
description,
customer: customerId,
automatic_payment_methods: {
enabled: true,
},
metadata: {
source: 'custom-form',
},
});
return NextResponse.json({
clientSecret: paymentIntent.client_secret,
paymentIntentId: paymentIntent.id,
});
} catch (error) {
console.error('Błąd tworzenia PaymentIntent:', error);
if (error instanceof Stripe.errors.StripeError) {
return NextResponse.json(
{ error: error.message },
{ status: error.statusCode || 500 }
);
}
return NextResponse.json(
{ error: 'Wewnętrzny błąd serwera' },
{ status: 500 }
);
}
}
Stripe Elements — bezpieczny formularz płatności#
Stripe Elements to zestaw wstępnie zbudowanych komponentów UI, które bezpiecznie zbierają dane karty płatniczej. Dane karty nigdy nie trafiają na Twój serwer — są przesyłane bezpośrednio do Stripe.
// components/PaymentForm.tsx
'use client';
import { useState, FormEvent } from 'react';
import {
Elements,
PaymentElement,
useStripe,
useElements,
} from '@stripe/react-stripe-js';
import { getStripe } from '@/lib/stripe-client';
import type { StripeElementsOptions } from '@stripe/stripe-js';
function CheckoutForm() {
const stripe = useStripe();
const elements = useElements();
const [error, setError] = useState<string | null>(null);
const [processing, setProcessing] = useState(false);
const [succeeded, setSucceeded] = useState(false);
const handleSubmit = async (e: FormEvent) => {
e.preventDefault();
if (!stripe || !elements) return;
setProcessing(true);
setError(null);
const { error: submitError } = await elements.submit();
if (submitError) {
setError(submitError.message || 'Wystąpił błąd');
setProcessing(false);
return;
}
const { error: confirmError } = await stripe.confirmPayment({
elements,
confirmParams: {
return_url: `${window.location.origin}/checkout/success`,
},
});
if (confirmError) {
setError(confirmError.message || 'Płatność nie powiodła się');
setProcessing(false);
} else {
setSucceeded(true);
}
};
if (succeeded) {
return (
<div className="text-center p-8">
<h2 className="text-2xl font-bold text-green-600">
Płatność zakończona sukcesem!
</h2>
</div>
);
}
return (
<form onSubmit={handleSubmit} className="max-w-md mx-auto space-y-6">
<PaymentElement />
{error && (
<div className="text-red-500 text-sm bg-red-50 p-3 rounded">
{error}
</div>
)}
<button
type="submit"
disabled={!stripe || processing}
className="w-full bg-indigo-600 text-white py-3 px-4 rounded-lg
hover:bg-indigo-700 disabled:opacity-50 transition-colors"
>
{processing ? 'Przetwarzanie...' : 'Zapłać'}
</button>
</form>
);
}
interface PaymentFormProps {
clientSecret: string;
}
export function PaymentForm({ clientSecret }: PaymentFormProps) {
const options: StripeElementsOptions = {
clientSecret,
appearance: {
theme: 'stripe',
variables: {
colorPrimary: '#4f46e5',
borderRadius: '8px',
},
},
};
return (
<Elements stripe={getStripe()} options={options}>
<CheckoutForm />
</Elements>
);
}
Webhooki Stripe#
Webhooki to mechanizm, dzięki któremu Stripe informuje Twoją aplikację o zdarzeniach (np. udana płatność, nieudana subskrypcja, zwrot). Jest to jedyny niezawodny sposób potwierdzenia płatności — nigdy nie polegaj wyłącznie na przekierowaniu klienta na stronę sukcesu.
// app/api/webhooks/stripe/route.ts
import { NextRequest, NextResponse } from 'next/server';
import { stripe } from '@/lib/stripe';
import Stripe from 'stripe';
// Wyłączenie automatycznego parsowania body
export const runtime = 'nodejs';
async function handleCheckoutComplete(
session: Stripe.Checkout.Session
) {
console.log('Checkout zakończony:', session.id);
// Zaktualizuj zamówienie w bazie danych
// await db.order.update({
// where: { stripeSessionId: session.id },
// data: { status: 'paid', paidAt: new Date() },
// });
// Wyślij e-mail z potwierdzeniem
// await sendOrderConfirmation(session.customer_email);
}
async function handlePaymentFailed(
paymentIntent: Stripe.PaymentIntent
) {
console.error('Płatność nieudana:', paymentIntent.id);
// Powiadom użytkownika o nieudanej płatności
}
async function handleSubscriptionUpdated(
subscription: Stripe.Subscription
) {
console.log('Subskrypcja zaktualizowana:', subscription.id);
// Zaktualizuj status subskrypcji w bazie danych
}
async function handleInvoicePaid(invoice: Stripe.Invoice) {
console.log('Faktura opłacona:', invoice.id);
// Zarejestruj opłaconą fakturę
}
export async function POST(request: NextRequest) {
const body = await request.text();
const signature = request.headers.get('stripe-signature');
if (!signature) {
return NextResponse.json(
{ error: 'Brak nagłówka stripe-signature' },
{ status: 400 }
);
}
let event: Stripe.Event;
try {
event = stripe.webhooks.constructEvent(
body,
signature,
process.env.STRIPE_WEBHOOK_SECRET!
);
} catch (err) {
const message = err instanceof Error ? err.message : 'Nieznany błąd';
console.error('Weryfikacja webhooka nieudana:', message);
return NextResponse.json(
{ error: `Webhook Error: ${message}` },
{ status: 400 }
);
}
try {
switch (event.type) {
case 'checkout.session.completed':
await handleCheckoutComplete(
event.data.object as Stripe.Checkout.Session
);
break;
case 'payment_intent.payment_failed':
await handlePaymentFailed(
event.data.object as Stripe.PaymentIntent
);
break;
case 'customer.subscription.updated':
await handleSubscriptionUpdated(
event.data.object as Stripe.Subscription
);
break;
case 'invoice.paid':
await handleInvoicePaid(event.data.object as Stripe.Invoice);
break;
default:
console.log(`Nieobsługiwane zdarzenie: ${event.type}`);
}
return NextResponse.json({ received: true });
} catch (error) {
console.error('Błąd obsługi webhooka:', error);
return NextResponse.json(
{ error: 'Błąd przetwarzania webhooka' },
{ status: 500 }
);
}
}
Subskrypcje i rozliczenia cykliczne#
Stripe Billing umożliwia zarządzanie subskrypcjami. Możesz tworzyć plany cenowe (Products i Prices) w Stripe Dashboard, a następnie oferować je klientom.
// app/api/subscriptions/route.ts
import { NextRequest, NextResponse } from 'next/server';
import { stripe } from '@/lib/stripe';
interface SubscriptionRequest {
email: string;
priceId: string;
paymentMethodId: string;
}
export async function POST(request: NextRequest) {
try {
const { email, priceId, paymentMethodId }: SubscriptionRequest =
await request.json();
// Znajdź lub utwórz klienta
const customers = await stripe.customers.list({
email,
limit: 1,
});
let customer = customers.data[0];
if (!customer) {
customer = await stripe.customers.create({
email,
payment_method: paymentMethodId,
invoice_settings: {
default_payment_method: paymentMethodId,
},
});
} else {
await stripe.paymentMethods.attach(paymentMethodId, {
customer: customer.id,
});
await stripe.customers.update(customer.id, {
invoice_settings: {
default_payment_method: paymentMethodId,
},
});
}
// Utwórz subskrypcję
const subscription = await stripe.subscriptions.create({
customer: customer.id,
items: [{ price: priceId }],
payment_settings: {
payment_method_types: ['card'],
save_default_payment_method: 'on_subscription',
},
expand: ['latest_invoice.payment_intent'],
});
const invoice = subscription.latest_invoice as Stripe.Invoice;
const paymentIntent =
invoice.payment_intent as Stripe.PaymentIntent;
return NextResponse.json({
subscriptionId: subscription.id,
clientSecret: paymentIntent.client_secret,
status: subscription.status,
});
} catch (error) {
console.error('Błąd tworzenia subskrypcji:', error);
return NextResponse.json(
{ error: 'Nie udało się utworzyć subskrypcji' },
{ status: 500 }
);
}
}
// Zarządzanie subskrypcją — anulowanie
export async function DELETE(request: NextRequest) {
try {
const { subscriptionId } = await request.json();
const subscription = await stripe.subscriptions.update(
subscriptionId,
{
cancel_at_period_end: true,
}
);
return NextResponse.json({
status: subscription.status,
cancelAt: subscription.cancel_at,
});
} catch (error) {
console.error('Błąd anulowania subskrypcji:', error);
return NextResponse.json(
{ error: 'Nie udało się anulować subskrypcji' },
{ status: 500 }
);
}
}
SCA i 3D Secure#
Strong Customer Authentication (SCA) to wymóg regulacyjny w Europie (PSD2), który wymusza dodatkowe uwierzytelnianie płatności. Stripe automatycznie obsługuje 3D Secure, gdy jest to wymagane. Wystarczy użyć Payment Intents API z opcją automatic_payment_methods:
// Stripe automatycznie wywoła 3D Secure, gdy bank tego wymaga.
// Po stronie klienta wystarczy obsłużyć stan requires_action:
const { error, paymentIntent } = await stripe.confirmCardPayment(
clientSecret,
{
payment_method: {
card: elements.getElement('card')!,
billing_details: {
name: 'Jan Kowalski',
email: 'jan@example.com',
},
},
}
);
if (error) {
// Użytkownik nie przeszedł weryfikacji 3D Secure
console.error('Błąd 3DS:', error.message);
} else if (paymentIntent?.status === 'succeeded') {
// Płatność zakończona sukcesem
console.log('Płatność potwierdzona');
}
Obsługa wielu walut#
Stripe obsługuje ponad 135 walut. Możesz dynamicznie ustawiać walutę w zależności od lokalizacji użytkownika:
// lib/currency.ts
interface CurrencyConfig {
currency: string;
locale: string;
symbol: string;
minAmount: number;
}
const CURRENCY_MAP: Record<string, CurrencyConfig> = {
PL: { currency: 'pln', locale: 'pl-PL', symbol: 'zł', minAmount: 200 },
DE: { currency: 'eur', locale: 'de-DE', symbol: '€', minAmount: 50 },
US: { currency: 'usd', locale: 'en-US', symbol: '$', minAmount: 50 },
GB: { currency: 'gbp', locale: 'en-GB', symbol: '£', minAmount: 30 },
};
export function getCurrencyConfig(
countryCode: string
): CurrencyConfig {
return CURRENCY_MAP[countryCode] || CURRENCY_MAP['US'];
}
export function formatAmount(
amount: number,
currency: string,
locale: string
): string {
return new Intl.NumberFormat(locale, {
style: 'currency',
currency: currency.toUpperCase(),
}).format(amount / 100);
}
Testowanie z Stripe CLI#
Stripe CLI pozwala symulować zdarzenia webhookowe lokalnie, bez konieczności wdrażania aplikacji:
// 1. Zainstaluj Stripe CLI
// brew install stripe/stripe-cli/stripe (macOS)
// scoop install stripe (Windows)
// 2. Zaloguj się
// stripe login
// 3. Nasłuchuj webhooków lokalnie
// stripe listen --forward-to localhost:3000/api/webhooks/stripe
// 4. Wyślij testowe zdarzenie
// stripe trigger payment_intent.succeeded
// 5. Testowe numery kart:
// 4242 4242 4242 4242 — płatność udana
// 4000 0025 0000 3155 — wymaga 3D Secure
// 4000 0000 0000 9995 — płatność odrzucona
Stripe CLI wyświetli webhook signing secret (zaczynający się od whsec_), który musisz ustawić w zmiennej STRIPE_WEBHOOK_SECRET.
PCI Compliance#
Zgodność z PCI DSS jest obowiązkowa dla każdego, kto przetwarza dane kart płatniczych. Korzystając ze Stripe Elements lub Checkout, uzyskujesz najwyższy poziom uproszczenia:
- SAQ A — formularz Checkout (hostowany przez Stripe) — najmniej wymagań
- SAQ A-EP — Stripe Elements (embedded w Twojej stronie) — umiarkowane wymagania
- Dane karty nigdy nie dotykają Twojego serwera — trafiają bezpośrednio do Stripe
- Stripe jest certyfikowany jako PCI DSS Level 1 — najwyższy poziom bezpieczeństwa
Zadbaj o HTTPS w swoim środowisku produkcyjnym i nie przechowuj żadnych surowych danych kart.
Stripe Dashboard#
Stripe Dashboard to centrum zarządzania Twoimi płatnościami. Oferuje:
- Podgląd transakcji w czasie rzeczywistym z filtrami i wyszukiwarką
- Zarządzanie klientami — historia płatności, subskrypcje, metody płatności
- Zwroty — pełne i częściowe, bezpośrednio z panelu
- Raporty i analityka — przychody, wskaźniki konwersji, MRR (Monthly Recurring Revenue)
- Środowisko testowe — osobne API key i dane, bez ryzyka
- Logi API — podgląd każdego żądania z pełnym payloadem
- Radar — system wykrywania oszustw z regułami ML
Porównanie z innymi procesorami płatności#
| Cecha | Stripe | PayU | Przelewy24 | PayPal | |---|---|---|---|---| | API dla deweloperów | Doskonałe | Dobre | Średnie | Dobre | | Dokumentacja | Wzorcowa | Dobra | Podstawowa | Dobra | | Wsparcie TypeScript | Pełne | Częściowe | Brak | Częściowe | | Subskrypcje | Wbudowane | Zewnętrzne | Brak | Wbudowane | | 3D Secure | Automatyczne | Manualne | Automatyczne | Automatyczne | | Prowizje (Europa) | 1.4% + 0.25€ | 1.2% + 0.20€ | 1.2% | 2.9% + 0.35€ | | BLIK | Tak | Tak | Tak | Nie | | Przelewy24 | Tak | Nie | Tak | Nie | | Webhooki | Zaawansowane | Podstawowe | Podstawowe | Zaawansowane |
Stripe wyróżnia się doskonałym DX (Developer Experience), kompletnym SDK w TypeScript i najbogatszym ekosystemem integracji.
Obsługa błędów — najlepsze praktyki#
Solidna obsługa błędów jest kluczowa w systemach płatności. Oto wzorzec, który obejmuje najczęstsze scenariusze:
// lib/stripe-errors.ts
import Stripe from 'stripe';
interface StripeErrorResponse {
message: string;
code: string;
statusCode: number;
}
export function handleStripeError(
error: unknown
): StripeErrorResponse {
if (error instanceof Stripe.errors.StripeCardError) {
return {
message: getPolishCardErrorMessage(error.code),
code: error.code || 'card_error',
statusCode: 402,
};
}
if (error instanceof Stripe.errors.StripeRateLimitError) {
return {
message: 'Zbyt wiele żądań. Spróbuj ponownie za chwilę.',
code: 'rate_limit',
statusCode: 429,
};
}
if (error instanceof Stripe.errors.StripeInvalidRequestError) {
return {
message: 'Nieprawidłowe żądanie płatności.',
code: 'invalid_request',
statusCode: 400,
};
}
if (error instanceof Stripe.errors.StripeAuthenticationError) {
return {
message: 'Błąd konfiguracji płatności.',
code: 'auth_error',
statusCode: 500,
};
}
return {
message: 'Wystąpił nieoczekiwany błąd. Spróbuj ponownie.',
code: 'unknown',
statusCode: 500,
};
}
function getPolishCardErrorMessage(
code: string | undefined
): string {
const messages: Record<string, string> = {
card_declined: 'Karta została odrzucona.',
insufficient_funds: 'Niewystarczające środki na karcie.',
expired_card: 'Karta wygasła.',
incorrect_cvc: 'Nieprawidłowy kod CVC.',
processing_error: 'Błąd przetwarzania. Spróbuj ponownie.',
incorrect_number: 'Nieprawidłowy numer karty.',
};
return messages[code || ''] || 'Płatność nie powiodła się.';
}
Walidacja po stronie serwera#
Nigdy nie ufaj danym przychodzącym z klienta. Każde żądanie płatności powinno przejść walidację po stronie serwera:
// lib/validation.ts
import { z } from 'zod';
export const paymentSchema = z.object({
amount: z
.number()
.int('Kwota musi być liczbą całkowitą')
.min(200, 'Minimalna kwota to 2.00 PLN')
.max(99999999, 'Kwota przekracza limit'),
currency: z.enum(['pln', 'eur', 'usd', 'gbp']),
description: z
.string()
.max(500, 'Opis nie może przekraczać 500 znaków')
.optional(),
customerEmail: z.string().email('Nieprawidłowy adres e-mail').optional(),
metadata: z.record(z.string()).optional(),
});
export type PaymentInput = z.infer<typeof paymentSchema>;
// Użycie w API Route:
// const result = paymentSchema.safeParse(body);
// if (!result.success) {
// return NextResponse.json(
// { error: result.error.flatten() },
// { status: 400 }
// );
// }
Podsumowanie#
Integracja Stripe z Next.js daje ogromne możliwości — od prostych jednorazowych płatności po zaawansowane systemy subskrypcyjne. Kluczowe elementy udanej integracji to:
- Stripe Elements lub Checkout do bezpiecznego zbierania danych kart
- Payment Intents API do elastycznych przepływów płatności
- Webhooki jako jedyne źródło prawdy o statusie transakcji
- Walidacja po stronie serwera każdego żądania
- Obsługa błędów z czytelnymi komunikatami dla użytkowników
- Stripe CLI do testowania webhooków lokalnie
- SCA/3D Secure obsługiwane automatycznie przez Payment Intents
Stripe w połączeniu z Next.js i TypeScript tworzy solidne, typowane i bezpieczne rozwiązanie płatnicze, gotowe na skalowanie.
Potrzebujesz profesjonalnej integracji płatności Stripe w swojej aplikacji Next.js? Zespół MDS Software Solutions Group specjalizuje się w budowie bezpiecznych systemów e-commerce i SaaS z pełną obsługą płatności online. Skontaktuj się z nami, aby omówić Twój projekt i otrzymać bezpłatną wycenę.
Zespół ekspertów programistycznych specjalizujących się w nowoczesnych technologiach webowych.