· stripe / nextjs / payments

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

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_pk_test_ dùng môi trường sandbox của Stripe — không có tiền thật. Khi lên production, đổi sang sk_live_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ả searchParamsparams đề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 CheckoutEmbedded Checkout
UXChuyển hướng đến checkout.stripe.comGiữ nguyên domain của bạn (iframe)
Cài đặtĐơn giản hơn — không cần package thêmCần @stripe/react-stripe-js
Kiểm soát thương hiệuHiển thị logo StripeDomain của bạn xuyên suốt
Tuân thủ PCIStripe xử lý 100%Stripe xử lý 100%
ui_modeBỏ qua (hoặc 'hosted')'embedded_page'
Tham số trả vềsuccess_urlreturn_url
Client secretKhông cầnBắt buộc
Khi nào dùngTích hợp nhanh nhấtTrả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ỗiHậu quảCách xử lý
Dùng req.json() trong webhook handlerXá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áoTrả về 400 cho mọi webhook, đơn hàng bị bỏ lỡ âm thầmKhai báo biến env; chạy stripe listen để lấy secret local
Nhầm price ID với price_dataNhầm lẫn hai API overload khác nhauDù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 routefetch phía client gọi đến origin khácDùng Server Actions (Embedded mode) hoặc set CORS headers trong Route Handler
Không await searchParamsLỗi runtime trong Next.js 15const { session_id } = await searchParams — cả hai props là Promise trong Next.js 15
Không kiểm tra webhook trùng lặpStripe retry webhook thất bại — đơn hàng bị xử lý hai lầnLưu session.id và bỏ qua nếu đã xử lý
Giá tiền đến từ phía clientNgười dùng có thể thay đổi số tiềnLuô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 bundleStripe từ chối; đây cũng là lỗ hổng bảo mậtChỉ 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.

Deploy with Vercel

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

  1. Stripe Hosted Checkout quickstart — verified 2026-05-30
  2. Stripe Embedded Checkout quickstart — verified 2026-05-30
  3. Next.js Route Handlers — v16.2.6, updated 2026-05-28
  4. stripe-node v22.2.0 release — confirmed latest