Cách tích hợp Stripe Checkout vào ứng dụng Next.js (2026)
Hướng dẫn từng bước: thêm Stripe Hosted hoặc Embedded Checkout vào Next.js 15 App Router. Bao gồm Checkout Sessions, webhooks, và lưu ý về req.text().
Bởi Ethan
1.603 từ · 9 phút đọc
Stripe Checkout là cách nhanh nhất để thêm tính năng thanh toán vào ứng dụng Next.js. Có hai chế độ: Hosted (chuyển hướng đến checkout.stripe.com) và Embedded (hiển thị iframe của Stripe ngay trên trang của bạn). Hosted dễ tích hợp hơn; Embedded giữ người dùng ở lại domain của bạn. Cả hai đều tuân thủ PCI — Stripe xử lý toàn bộ dữ liệu thẻ. Chọn Hosted trước, trừ khi yêu cầu thương hiệu bắt buộc phải dùng iframe.
Bài viết này dành cho ai
Các developer đang xây dựng dự án Next.js 15 (App Router) cần thu phí người dùng và muốn Stripe xử lý giao diện thanh toán. Nếu bạn cần form thanh toán tùy chỉnh hoàn toàn, hãy xem Stripe Elements — Checkout không phải là công cụ đó.
Những gì chúng tôi đã thử nghiệm
stripe npm package v22.2.0, Stripe API version 2026-05-27.dahlia, Next.js 16.x, @stripe/react-stripe-js (chỉ cho Embedded mode). Tất cả đoạn code dưới đây đều được lấy từ code chạy thực tế với các phiên bản này.
Cài đặt
# Cả hai mode
npm install stripe @stripe/stripe-js
# Chỉ Embedded mode
npm install @stripe/react-stripe-js
Khai báo các biến môi trường trong .env.local:
STRIPE_SECRET_KEY=sk_test_51...
NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=pk_test_51...
STRIPE_WEBHOOK_SECRET=whsec_...
Test vs live mode: Key
sk_test_vàpk_test_dùng môi trường sandbox của Stripe — không có tiền thật. Khi lên production, đổi sangsk_live_vàpk_live_. Sản phẩm và giá tạo trong test mode không hiển thị ở live mode; bạn cần tạo lại hoặc dùng Stripe CLI để seed cả hai môi trường.
Tạo một Stripe client singleton để import phía server (lib/stripe.ts):
import Stripe from 'stripe'
export const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!, {
apiVersion: '2026-05-27.dahlia',
})
Chỉ định apiVersion giúp tránh bị ảnh hưởng bởi breaking changes khi Stripe ra phiên bản mới.
Hosted Checkout
Người dùng nhấn nút, gửi yêu cầu đến API route của bạn, và được chuyển đến checkout.stripe.com. Sau khi thanh toán xong, Stripe chuyển hướng họ về success_url.
1. Tạo Checkout Session (phía server)
Thêm Route Handler tại app/api/checkout/route.ts:
import { stripe } from '@/lib/stripe'
import { NextResponse } from 'next/server'
export async function POST(request: Request) {
const origin = request.headers.get('origin')
const session = await stripe.checkout.sessions.create({
line_items: [
{
price: process.env.STRIPE_PRICE_ID!, // Chỉ phía server — không bao giờ tin số tiền từ phía client
quantity: 1,
},
],
mode: 'payment', // 'payment' | 'subscription' | 'setup'
success_url: `${origin}/success?session_id={CHECKOUT_SESSION_ID}`,
cancel_url: `${origin}/`,
automatic_tax: { enabled: true },
})
return NextResponse.redirect(session.url!, 303)
}
Luôn tra cứu price ID phía server. Không bao giờ nhận giá tiền từ client — người dùng có thể thay đổi nó.
2. Nút Checkout (phía client)
Một form HTML đơn giản POST lên Route Handler. Không cần JavaScript phía client:
export default function HomePage() {
return (
<form action="/api/checkout" method="POST">
<button type="submit">Mua ngay — $20</button>
</form>
)
}
3. Trang thành công
Stripe thêm ?session_id=... vào success_url của bạn. Truy xuất session phía server để xác nhận thanh toán:
import { stripe } from '@/lib/stripe'
import { redirect } from 'next/navigation'
export default async function SuccessPage({
searchParams,
}: {
searchParams: Promise<{ session_id?: string }>
}) {
const { session_id } = await searchParams // Next.js 15: searchParams là một Promise
if (!session_id) redirect('/')
const session = await stripe.checkout.sessions.retrieve(session_id, {
expand: ['line_items', 'payment_intent'],
})
if (session.status === 'open') redirect('/') // Thanh toán chưa hoàn tất
return (
<section>
<h1>Thanh toán thành công!</h1>
<p>
Cảm ơn {session.customer_details?.name}. Biên lai đã được gửi đến{' '}
{session.customer_details?.email}.
</p>
</section>
)
}
Chú ý await searchParams — trong Next.js 15, cả searchParams và params đều là Promise. Cách truy cập trực tiếp của Next.js 14 sẽ gây lỗi runtime.
Embedded Checkout
Người dùng không rời khỏi domain của bạn. Stripe hiển thị form thanh toán bên trong iframe trên trang của bạn. Bạn cần một client secret từ Checkout Session và package @stripe/react-stripe-js.
1. Tạo session (server action)
'use server'
import { stripe } from '@/lib/stripe'
import { headers } from 'next/headers'
export async function fetchClientSecret(): Promise<string> {
const origin = (await headers()).get('origin')
const session = await stripe.checkout.sessions.create({
ui_mode: 'embedded_page', // Không phải 'embedded' — tài liệu hiện dùng 'embedded_page'
line_items: [
{
price: process.env.STRIPE_PRICE_ID!,
quantity: 1,
},
],
mode: 'payment',
return_url: `${origin}/return?session_id={CHECKOUT_SESSION_ID}`,
automatic_tax: { enabled: true },
})
return session.client_secret!
}
2. Hiển thị form thanh toán (phía client)
'use client'
import {
EmbeddedCheckout,
EmbeddedCheckoutProvider,
} from '@stripe/react-stripe-js'
import { loadStripe } from '@stripe/stripe-js'
import { fetchClientSecret } from '@/app/actions/stripe'
const stripePromise = loadStripe(
process.env.NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY!
)
export default function CheckoutForm() {
return (
<EmbeddedCheckoutProvider
stripe={stripePromise}
options={{ fetchClientSecret }}
>
<EmbeddedCheckout />
</EmbeddedCheckoutProvider>
)
}
Gắn component này vào trang /checkout:
import CheckoutForm from '@/components/CheckoutForm'
export default function CheckoutPage() {
return (
<main>
<CheckoutForm />
</main>
)
}
3. Trang trả về
Sau khi thanh toán, Stripe gọi return_url với ?session_id=...:
import { stripe } from '@/lib/stripe'
import { redirect } from 'next/navigation'
export default async function ReturnPage({
searchParams,
}: {
searchParams: Promise<{ session_id?: string }>
}) {
const { session_id } = await searchParams
if (!session_id) redirect('/')
const session = await stripe.checkout.sessions.retrieve(session_id)
if (session.status === 'open') redirect('/checkout')
return (
<section>
<h1>Thanh toán hoàn tất!</h1>
<p>Xác nhận đã gửi đến {session.customer_details?.email}</p>
</section>
)
}
So sánh nhanh: Hosted và Embedded
| Hosted Checkout | Embedded Checkout | |
|---|---|---|
| UX | Chuyển hướng đến checkout.stripe.com | Giữ nguyên domain của bạn (iframe) |
| Cài đặt | Đơn giản hơn — không cần package thêm | Cần @stripe/react-stripe-js |
| Kiểm soát thương hiệu | Hiển thị logo Stripe | Domain của bạn xuyên suốt |
| Tuân thủ PCI | Stripe xử lý 100% | Stripe xử lý 100% |
ui_mode | Bỏ qua (hoặc 'hosted') | 'embedded_page' |
| Tham số trả về | success_url | return_url |
| Client secret | Không cần | Bắt buộc |
| Khi nào dùng | Tích hợp nhanh nhất | Trải nghiệm thanh toán đồng bộ thương hiệu |
Webhooks
Cả hai mode đều cần webhooks để xử lý đơn hàng đáng tin cậy. Đừng xử lý đơn hàng chỉ dựa vào trang success — người dùng có thể đóng tab trước khi trang tải xong.
Thêm app/api/webhooks/route.ts:
import { stripe } from '@/lib/stripe'
import { headers } from 'next/headers'
export async function POST(req: Request) {
const body = await req.text() // PHẢI là raw text — req.json() phá vỡ xác minh chữ ký
const signature = (await headers()).get('stripe-signature')!
let event
try {
event = stripe.webhooks.constructEvent(
body,
signature,
process.env.STRIPE_WEBHOOK_SECRET!
)
} catch (err: any) {
return new Response(`Webhook error: ${err.message}`, { status: 400 })
}
switch (event.type) {
case 'checkout.session.completed':
const session = event.data.object
// Xử lý tại đây — ghi vào DB, gửi email, cấp quyền truy cập
break
}
return new Response(null, { status: 200 })
}
Test ở local với Stripe CLI:
stripe listen --forward-to localhost:3000/api/webhooks
stripe trigger checkout.session.completed
CLI in ra signing secret whsec_... — dán vào STRIPE_WEBHOOK_SECRET.
Những lỗi phổ biến
| Lỗi | Hậu quả | Cách xử lý |
|---|---|---|
Dùng req.json() trong webhook handler | Xác minh chữ ký thất bại — body bị serialize lại và không còn khớp với raw bytes Stripe đã ký | Luôn dùng await req.text() |
STRIPE_WEBHOOK_SECRET chưa được khai báo | Trả về 400 cho mọi webhook, đơn hàng bị bỏ lỡ âm thầm | Khai báo biến env; chạy stripe listen để lấy secret local |
Nhầm price ID với price_data | Nhầm lẫn hai API overload khác nhau | Dùng price ID tạo sẵn cho sản phẩm cố định; price_data chỉ cho giá động một lần |
| Lỗi CORS trên API route | fetch phía client gọi đến origin khác | Dùng Server Actions (Embedded mode) hoặc set CORS headers trong Route Handler |
Không await searchParams | Lỗi runtime trong Next.js 15 | const { session_id } = await searchParams — cả hai props là Promise trong Next.js 15 |
| Không kiểm tra webhook trùng lặp | Stripe retry webhook thất bại — đơn hàng bị xử lý hai lần | Lưu session.id và bỏ qua nếu đã xử lý |
| Giá tiền đến từ phía client | Người dùng có thể thay đổi số tiền | Luôn lấy price ID phía server; không bao giờ tin số từ phía client |
| Secret key lọt vào client bundle | Stripe từ chối; đây cũng là lỗ hổng bảo mật | Chỉ NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY được dùng phía client |
Bước tiếp theo
Khi thanh toán đã hoạt động, bạn sẽ muốn gửi email xác nhận cho khách hàng. Resend là lựa chọn đáng tin cậy — gửi email transactional từ webhook handler checkout.session.completed chỉ với vài dòng code.
Deploy ứng dụng lên Vercel — đây là nền tảng phù hợp nhất cho Next.js, xử lý biến môi trường, edge functions, và preview deployment mà không cần cấu hình thêm.
Nếu bạn vẫn đang cân nhắc liệu Stripe có phải lựa chọn phù hợp, xem Stripe vs Paddle để so sánh merchant of record, hoặc Lemon Squeezy vs Stripe nếu bạn muốn giải pháp all-in-one không lo về tuân thủ thuế.
Tài liệu tham khảo
- Stripe Hosted Checkout quickstart — verified 2026-05-30
- Stripe Embedded Checkout quickstart — verified 2026-05-30
- Next.js Route Handlers — v16.2.6, updated 2026-05-28
- stripe-node v22.2.0 release — confirmed latest