news 2026/9/8 23:48:56

基于 Next.js App Router 与 TypeScript 集成 Stripe:Checkout / Elements / Webhook 全流程实战指南

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
基于 Next.js App Router 与 TypeScript 集成 Stripe:Checkout / Elements / Webhook 全流程实战指南

基于 Next.js App Router 与 TypeScript 集成 Stripe:Checkout / Elements / Webhook 全流程实战指南

【免费下载链接】next.jsThe React Framework项目地址: https://gitcode.com/GitHub_Trending/next/next.js

本文以 Next.js 官方仓库中with-stripe-typescript示例(捐赠支付)为完整蓝本,讲解在 Next.js App Router + TypeScript 项目中端到端接入 Stripe 支付的标准姿势:如何用 Server Actions 创建 Checkout Session 与 PaymentIntent、如何在客户端用 react-stripe-js 渲染支付表单、如何在服务端通过 Route Handler 校验并消费 Webhook。读完本文,你将掌握一套可复制的“服务端创建支付凭证 + 客户端安全采集卡信息 + 异步对账”的支付集成框架,并了解金额格式化、零货币小数、webhook 签名验证等易踩坑细节。

示例概览:它解决什么问题

该示例是一个全栈 TypeScript 捐赠应用,代码位于仓库的 examples/with-stripe-typescript 目录,核心依赖如下(见 package.json):

  • 前端:Next.js + @stripe/react-stripe-js 提供的<Elements>/<PaymentElement>,用于受控采集卡号等敏感信息,PCI 合规由 Stripe.js 接管;
  • 后端:Next.js 的Route Handlers(处理 Webhook)与Server Actions(创建支付会话 / PaymentIntent),配合同样用 TypeScript 编写的stripe-node官方 SDK。

示例同时演示了三种主流的支付形态,且在测试模式下即可完整体验:

  1. 托管 Checkout(Hosted Checkout):跳转到 Stripe 托管的支付页完成支付,跳转页由 Stripe 全权渲染;
  2. 嵌入式 Checkout(Embedded Checkout):同一套 Server Action,通过ui_mode: "embedded"配合client_secret在页面内嵌支付页;
  3. Stripe Elements(自定义 UI):通过 PaymentIntent 在自有页面渲染 Stripe 支付组件(Payment Element)。

后文将逐一从源码拆解这三条调用链。

Demo 与测试卡片

示例提供在线演示,默认运行在 Stripetest mode。测试环境中使用以下固定卡号:

  • 常规成功支付:卡号4242424242424242,配合任意 CVC 与未来有效期;
  • 触发 3D Secure 验证流程:使用卡号4000002760003184

Stripe 的完整测试场景(拒付、银行拒绝、跨币种等)都有对应的专用卡号,可在 Stripe 官方测试文档中按需选用。项目的首页组件中也内置了测试卡片提示(相关代码位于 components/StripeTestCards.tsx,可用于展示给访客)。

下图展示了两种捐赠入口的实际交互效果(Demo 动图存于示例的 public 目录):

核心目录与文件职责

在深入代码前,先建立“哪个文件负责什么”的地图(路径均相对仓库根目录):

目录 / 文件职责
app/donate-with-checkout/page.tsx“托管 Checkout”捐赠页(服务端组件),渲染CheckoutForm
app/donate-with-checkout/result/page.tsxCheckout 成功回跳页,用session_id拉取 Checkout Session 对象
app/donate-with-elements/page.tsx“Elements”捐赠页(服务端组件),渲染ElementsForm
app/donate-with-elements/result/page.tsxPaymentIntent 成功回跳页,用payment_intent参数拉取对象
app/donate-with-embedded-checkout/嵌入式 Checkout 相关页面(与上面两个流程共用同一 Server Action)
app/actions/stripe.tsServer Actions:createCheckoutSession/createPaymentIntent
app/api/webhooks/route.tsRoute Handler:接收 Stripe Webhook 并做签名校验
lib/stripe.ts初始化stripe-node单例(含server-only保护)
config/index.ts货币与金额上下限配置
utils/stripe-helpers.ts金额展示 / 提交格式转换
utils/get-stripejs.ts懒加载 Stripe.js(返回 Promise)
components/表单、金额输入、结果 JSON 打印等客户端组件

前端与服务端组件的边界很清晰:页面是 Server Component(负责向服务端发起“创建支付会话”的调用并渲染元数据),表单是 Client Component(用 react-stripe-js 完成卡的采集与确认)。目录内各页面还配有error.tsx与独立的layout.tsx,处理加载失败与结果页布局。

快速开始:本地跑起来

示例可以用create-next-app--example参数直接脚手架生成(三种包管理器任选其一):

npx create-next-app --example with-stripe-typescript with-stripe-typescript-app
yarn create next-app --example with-stripe-typescript with-stripe-typescript-app
pnpm create next-app --example with-stripe-typescript with-stripe-typescript-app

第一步:复制环境变量文件

把示例中的.env.local.example复制为项目根目录的.env.local

cp .env.local.example .env.local

.env.local.example(位于 examples/with-stripe-typescript/.env.local.example)的内容决定了本示例需要哪些密钥:

# Stripe keys # https://dashboard.stripe.com/apikeys NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=pk_12345 STRIPE_SECRET_KEY=sk_12345 STRIPE_PAYMENT_DESCRIPTION='Software development services' # https://stripe.com/docs/webhooks/signatures STRIPE_WEBHOOK_SECRET=whsec_1234

第二步:填入真实的 API Keys

运行本示例需要一个 Stripe 账号。登录 Stripe开发者后台后即可找到两组密钥并替换占位值:

环境变量对应密钥使用场景
NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEYPublishable Key(pk_...NEXT_PUBLIC_前缀暴露给浏览器,供 Stripe.js 初始化使用
STRIPE_SECRET_KEYSecret Key(sk_...仅存服务端,供stripe-node创建 Checkout Session / PaymentIntent / 查询对象
STRIPE_WEBHOOK_SECRETWebhook 签名密钥(whsec_...仅在服务端用于校验 Webhook 事件签名,本地调试时由 Stripe CLI 生成
STRIPE_PAYMENT_DESCRIPTION自定义描述(可选)由开发者自行用于支付描述文案(字符串型)

这里有两个重要的边界约束,正是 lib/stripe.ts 第一行import "server-only"的原因:Secret Key 绝不能进客户端 bundle。用server-only包显式声明,一旦该模块被客户端代码误 import,构建阶段就会直接报错,从机制上杜绝密钥泄露。

第三步:安装依赖并启动

npm install npm run dev # 或 yarn yarn dev # 或 pnpm install pnpm dev

对应脚本定义在 package.json:dev=nextbuild=next buildstart=next start

金额与货币:配置驱动的边界控制

支付的金额不是任意写死的。示例把金额相关的业务规则收敛到 config/index.ts 一处:

export const CURRENCY = "usd"; // Set your amount limits: Use float for decimal currencies and // Integer for zero-decimal currencies: https://stripe.com/docs/currencies#zero-decimal. export const MIN_AMOUNT = 10.0; export const MAX_AMOUNT = 5000.0; export const AMOUNT_STEP = 5.0;
  • CURRENCY:结算币种,示例为usd
  • MIN_AMOUNT/MAX_AMOUNT:金额下限与上限(单位按币种约定,美元这类十进制货币用浮点),供客户端表单做校验,避免非预期的大额/小额请求打到 Stripe;
  • AMOUNT_STEP:允许输入/选择的金额步长。

金额格式化的关键坑:零小数货币

这是 Stripe 集成里最容易出 bug 的地方。Stripe API 内部一律使用“最小货币单位”(整数)表示金额:美元要乘 100(即“分”),而日元、韩元这类零小数货币则直接使用整数原值,不能乘 100。

utils/stripe-helpers.ts 中的formatAmountForStripe正是为处理这一差异而存在:

export function formatAmountForStripe( amount: number, currency: string, ): number { let numberFormat = new Intl.NumberFormat(["en-US"], { style: "currency", currency: currency, currencyDisplay: "symbol", }); const parts = numberFormat.formatToParts(amount); let zeroDecimalCurrency: boolean = true; for (let part of parts) { if (part.type === "decimal") { zeroDecimalCurrency = false; } } return zeroDecimalCurrency ? amount : Math.round(amount * 100); }

它的技巧是用Intl.NumberFormat格式化当前币种金额,然后检查formatToParts输出中是否包含decimal段:若存在小数点说明是十进制货币,转换为分(amount * 100);若没有小数点则判定为零小数货币,原样返回。同文件还提供了formatAmountForDisplay,把 Stripe 返回的整数金额重新格式化为用户可读的货币显示串。

支付流程一:托管 Checkout(Hosted Checkout)

“自定义金额 + 跳转 Stripe Checkout 支付页”是最少代码的接入方式。其入口页面 app/donate-with-checkout/page.tsx 是一个服务端组件:导出metadata设置页面标题,然后在默认导出组件中渲染客户端表单<CheckoutForm uiMode="hosted" />

服务端真正干活的是 app/actions/stripe.ts 里的 Server ActioncreateCheckoutSession

export async function createCheckoutSession( data: FormData, ): Promise<{ client_secret: string | null; url: string | null }> { const ui_mode = data.get("uiMode") as Stripe.Checkout.SessionCreateParams.UiMode; const origin: string = headers().get("origin") as string; const checkoutSession: Stripe.Checkout.Session = await stripe.checkout.sessions.create({ mode: "payment", submit_type: "donate", line_items: [ { quantity: 1, price_data: { currency: CURRENCY, product_data: { name: "Custom amount donation" }, unit_amount: formatAmountForStripe( Number(data.get("customDonation") as string), CURRENCY, ), }, }, ], ...(ui_mode === "hosted" && { success_url: `${origin}/donate-with-checkout/result?session_id={CHECKOUT_SESSION_ID}`, cancel_url: `${origin}/donate-with-checkout`, }), ...(ui_mode === "embedded" && { return_url: `${origin}/donate-with-embedded-checkout/result?session_id={CHECKOUT_SESSION_ID}`, }), ui_mode, }); return { client_secret: checkoutSession.client_secret, url: checkoutSession.url }; }

几个值得展开的实现细节:

  • "use server"指令:文件顶部的"use server"声明整个模块只运行在服务端。Server Action 直接接收表单FormData,表单里的customDonation(金额)与uiMode在此被读取;
  • 回跳 URL 动态拼接:通过next/headersheaders()拿到请求的origin,据此拼出success_urlcancel_url,这样部署在不同域名下也无需硬编码回调地址;
  • 动态行价格:金额走formatAmountForStripe转成 Stripe 整数分;mode: "payment"表示单次支付(非订阅),submit_type: "donate"让 Checkout 页呈现捐赠语义;
  • 一个 Action 服务两种模式ui_modehosted时返回url(整页跳转);为embedded时返回client_secret(供嵌入式 Checkout 在页内渲染)。仓库中同样存在 app/donate-with-embedded-checkout/ 页面目录,即嵌入式形态的落地页。

支付完成后 Stripe 会带着{CHECKOUT_SESSION_ID}占位符被替换成的真实session_id回跳到成功页。注意:此时仍不应信任浏览器带来的参数,应回源校验。成功页 app/donate-with-checkout/result/page.tsx 正是这样做的:它是个async服务端组件,收到searchParams.session_id后调用stripe.checkout.sessions.retrieve(session_id, { expand: ["line_items", "payment_intent"] })取回服务端真实状态,并把 PaymentIntent 状态展示出来;缺参时直接throw new Error("Please provide a valid session_id ...")交给同目录的error.tsx渲染。

支付流程二:Stripe Elements(PaymentIntent 自定义 UI)

如果不想跳走页面,而是把支付组件嵌到自己的表单里,就需要 PaymentIntent + Payment Element 组合。Server Action app/actions/stripe.ts 中的createPaymentIntent只做一件事——在服务端创建一个待支付的 PaymentIntent,并把client_secret交给前端:

export async function createPaymentIntent( data: FormData, ): Promise<{ client_secret: string }> { const paymentIntent: Stripe.PaymentIntent = await stripe.paymentIntents.create({ amount: formatAmountForStripe( Number(data.get("customDonation") as string), CURRENCY, ), automatic_payment_methods: { enabled: true }, currency: CURRENCY, }); return { client_secret: paymentIntent.client_secret as string }; }

要点说明:

  • automatic_payment_methods: { enabled: true }:让 Stripe 根据账户配置自动为 PaymentIntent 启用可用支付方式,开发期无需手工枚举卡、钱包等渠道;
  • client_secret不能落入日志或缓存,它用于在浏览器端完成支付确认,属于敏感凭证;
  • 前端流程(页面在 app/donate-with-elements/page.tsx,表单在 components/ElementsForm.tsx)大致是:先用elements提供的加载函数拿到 Stripe.js 实例(参见 utils/get-stripejs.ts),以NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY初始化;再通过<Elements>包裹自定义表单,在PaymentElement里采集卡信息;提交时调用stripe.confirmPayment({ clientSecret, return_url })。示例把return_url指向了同目录下的result页,Stripe 会在确认支付后带着payment_intent(形如pi_...)参数重定向回该页。

成功页 app/donate-with-elements/result/page.tsx 同样在服务端回源校验:用searchParams.payment_intent调用stripe.paymentIntents.retrieve(...)拉取真实状态并展示,缺少参数则抛出异常引导用户提供合法的pi_...值。为便于开发者调试,两条成功链路的页面都复用了 components/PrintObject.tsx,把 Stripe 返回的完整对象 JSON 打印在页面上——这正是检查“我到底拿到了哪些字段”最直观的手段。

支付流程三:Webhook 处理支付后事件

前端回跳只能拿到“同步”状态,真正可靠的支付结果确认要靠Webhook异步推送,例如用于发货、记账、更新订单状态。示例在 app/api/webhooks/route.ts 用一个 Route Handler 实现:

export async function POST(req: Request) { let event: Stripe.Event; try { event = stripe.webhooks.constructEvent( await (await req.blob()).text(), req.headers.get("stripe-signature") as string, process.env.STRIPE_WEBHOOK_SECRET as string, ); } catch (err) { const errorMessage = err instanceof Error ? err.message : "Unknown error"; if (!(err instanceof Error)) console.log(err); console.log(`❌ Error message: ${errorMessage}`); return NextResponse.json( { message: `Webhook Error: ${errorMessage}` }, { status: 400 }, ); } console.log("✅ Success:", event.id); const permittedEvents: string[] = [ "checkout.session.completed", "payment_intent.succeeded", "payment_intent.payment_failed", ]; if (permittedEvents.includes(event.type)) { let data; try { switch (event.type) { case "checkout.session.completed": data = event.data.object as Stripe.Checkout.Session; console.log(`💰 CheckoutSession status: ${data.payment_status}`); break; case "payment_intent.payment_failed": data = event.data.object as Stripe.PaymentIntent; console.log(`❌ Payment failed: ${data.last_payment_error?.message}`); break; case "payment_intent.succeeded": data = event.data.object as Stripe.PaymentIntent; console.log(`💰 PaymentIntent status: ${data.status}`); break; default: throw new Error(`Unhandled event: ${event.type}`); } } catch (error) { console.log(error); return NextResponse.json( { message: "Webhook handler failed" }, { status: 500 }, ); } } return NextResponse.json({ message: "Received" }, { status: 200 }); }

这段代码揭示了 Webhook 处理的两个硬性要求:

  1. 必须先验签再信内容stripe.webhooks.constructEventstripe-signature请求头与STRIPE_WEBHOOK_SECRET校验载荷签名与时间戳,验签失败会抛错并返回 400。收到请求的 payload 需先完整读出原文(示例通过(await req.blob()).text()消费 body),因为它要参与 HMAC 签名计算;
  2. 事件白名单 + 幂等处理心态。示例把允许处理的事件收敛为checkout.session.completedpayment_intent.succeededpayment_intent.payment_failed三个数组元素,switch分支分别按Checkout.Session/PaymentIntent强类型解析对象。真实项目中,分支内应改为写入自己的业务数据库并在处理前做事件去重(Stripe 会重试投递),生产逻辑不应只停留在console.log

返回响应也很有讲究:处理成功返回 200(示例回{ message: "Received" }),业务分支出错返回 500 以触发 Stripe 后续重试。

本地开发:用 Stripe CLI 转发 Webhook

本地机器没有公网地址,Stripe 无法直接回调。官方提供 Stripe CLI 做隧道转发。首先安装 CLI 并登录关联自己的 Stripe 账户,然后启动转发,把 Webhook 打到本地路由:

stripe listen --forward-to localhost:3000/api/webhooks

CLI 启动后会在控制台打印一个whsec_...的 webhook secret,把它填进.env.localSTRIPE_WEBHOOK_SECRET并重启 dev server,本地即可收到并验签真实事件。

生产:在 Stripe Dashboard 配置 Live Webhook

部署完成后,把“部署 URL + 路径”(形如https://your-url.vercel.app/api/webhooks)在 Stripe Dashboard 中登记为 live webhook endpoint,并把事件类型(如checkout.session.completedpayment_intent.succeeded)订阅到该 endpoint。创建后可查看并复制 endpoint 的签名密钥whsec_***,将其作为新的环境变量加入部署平台:

  • 在 Vercel 项目 Dashboard 进入 Settings;
  • 在 General settings 的 “Environment Variables” 区域新增STRIPE_WEBHOOK_SECRET
  • 新增环境变量后必须重新部署/重建,改动才会进入生产代码;在 Deployments 中选择最近一次部署,点击 “Visit” 旁的操作菜单并选择 “Redeploy”。

Stripe 客户端实例:配置项与版本策略

lib/stripe.ts 中实例化stripe-node的方式值得直接复用:

import "server-only"; import Stripe from "stripe"; export const stripe = new Stripe(process.env.STRIPE_SECRET_KEY as string, { apiVersion: "2023-10-16", appInfo: { name: "nextjs-with-stripe-typescript-demo", url: "https://nextjs-with-stripe-typescript-demo.vercel.app", }, });
  • apiVersion:显式固定 Stripe API 版本,避免 Stripe 升级 API 导致对象结构变化带来隐性破坏;示例包版本锁定stripe@14.8.0,在升级依赖时应留意 SDK 支持的 API 版本对应关系;
  • appInfo:把应用名与地址带给 Stripe,便于官方在排查请求时识别流量来源,属于工程化友好项;
  • 单例导出:模块级export const stripe确保整个服务进程复用同一个客户端连接配置。

部署到 Vercel

该应用可直接部署到 Vercel 云端(Next.js 官方部署文档有完整说明),两种方式:

  • 部署本地项目:把本地仓库推送到 Git 远端后导入 Vercel。关键提醒:导入项目时,务必在 Environment Variables 配置界面把三个变量(NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEYSTRIPE_SECRET_KEY,生产环境还需STRIPE_WEBHOOK_SECRET)逐一设置,使其与本地.env.local保持一致;
  • 部署官方模板:也可直接基于本示例模板一键克隆部署,模板已预置上述两个密钥的输入项(NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEYSTRIPE_SECRET_KEY),克隆后回到本地上文的流程补齐 Webhook 配置即可。

部署上线并配置好 live webhook 后,推荐再完整走一遍三类验证:用4242424242424242验证成功链路、用4000002760003184验证 3D Secure 挑战链路、在 Dashboard 的 Webhook 日志里确认checkout.session.completed/payment_intent.succeeded事件确实被你的 Route Handler 消费。

小结:一套可复用的集成范式

纵观整个示例,可以提炼出三条值得带走的原则:

  1. 服务端创建、客户端确认、异步对账三层分离:Checkout Session / PaymentIntent 一律由 Server Action + Secret Key 创建;卡信息只经 Stripe Elements/Stripe.js 采集与确认(client_secret留在浏览器);业务最终状态以 Webhook 为准;
  2. 金额边界全部收敛:币种、上下限、步长在config,展示/换算在utils,配合零小数货币判断,避免到处散落* 100
  3. 回跳页只展示、不信任:任何 success / return 回跳页都要用session_id/payment_intent回源 Stripe 校验真实状态,Webhook 则必须用STRIPE_WEBHOOK_SECRET验签后按事件白名单分支处理。

如需在自己的 Next.js + TypeScript 项目中复刻,可直接对照 examples/with-stripe-typescript 目录中的页面、Actions、Route Handler 与配置逐文件迁移。

【免费下载链接】next.jsThe React Framework项目地址: https://gitcode.com/GitHub_Trending/next/next.js

创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/9/8 23:48:26

90度FOV多区TOF传感器技术解析与落地实践

1. 这颗TOF芯片到底解决了什么真问题&#xff1f;“意法半导体全新多区测距TOF传感器&#xff1a;高达90度视场角堪比相机水准”——这标题里藏着三个被行业憋了很久的痛点&#xff0c;不是噱头&#xff0c;是实打实的工程突破。我做嵌入式视觉方案落地快十年了&#xff0c;从早…

作者头像 李华
网站建设 2026/9/8 23:48:16

C语言图书管理系统课程设计实战:结构体、链表与文件持久化解析

简介&#xff1a;面向计算机专业学生及C语言初学者的图书管理系统课程设计资源&#xff0c;以经典应用场景帮助读者把语言基础转化为实践项目。系统覆盖图书信息展示、入库登记、销售处理、条件查询、排序和修改等完整业务模块&#xff0c;实现过程中重点展示了结构体封装图书信…

作者头像 李华
网站建设 2026/9/8 23:46:40

Linux DRM子系统实战解析:从KMS到Atomic Commit

1. 项目概述&#xff1a;这不是一篇“历史课”&#xff0c;而是一份内核驱动工程师的实战备忘录 如果你在Linux图形栈里摸爬滚打过&#xff0c;大概率被drm_ioctl()返回的-EINVAL卡住过半天&#xff1b;如果你调试过一块RK3588板子上的HDMI输出&#xff0c;一定反复翻过drm_mod…

作者头像 李华
网站建设 2026/9/8 23:45:55

Kotlin协程启动方式全解析:launch、async与runBlocking的选型与避坑指南

协程用了一段时间&#xff0c;很多人的第一课是从 launch 开始的&#xff0c;然后写到一半发现代码不按顺序执行&#xff1b;换成 runBlocking 后界面卡死了&#xff1b;想拿返回值又硬着头皮用 GlobalScope.async 把程序搞崩了。这些我都经历过。Kotlin 协程的启动方式看…

作者头像 李华
网站建设 2026/9/8 23:45:51

用WorkBuddy搭建周报自动化流水线,把3天压缩到4小时

先说结论&#xff1a;这条周报流水线&#xff0c;我用了大概三周搭完&#xff0c;又磨合了两期&#xff0c;才敢说它稳定。以前我每次做统计周报&#xff0c;从收集各科室上报的 Excel、清洗异常值、核对同比环比&#xff0c;再到组织语言写分析段落、按单位模板排版&#xff0…

作者头像 李华
网站建设 2026/9/8 23:45:37

Atmosphere DNS重定向配置:三步悄悄屏蔽任天堂遥测服务器

Atmosphere DNS重定向配置&#xff1a;三步悄悄屏蔽任天堂遥测服务器 【免费下载链接】Atmosphere Atmosphre is a work-in-progress customized firmware for the Nintendo Switch. 项目地址: https://gitcode.com/GitHub_Trending/at/Atmosphere 每次开机联网&#xf…

作者头像 李华