Next.js integration (App Router)
Who this is for
E-commerce stores and SaaS apps built on Next.js 13+ with the App Router. Its Route Handlers are server-side code by construction, which makes them the natural home for the "call the platform with the API KEY" step — this is the framework that fits our API's split most directly.
Before you start
- You have an API KEY (see Get started).
- You have configured a collection address and set it active.
- Environment variables (server-only — never prefixed with
NEXT_PUBLIC_):
bash
# .env.local
API_BASE=https://api.web3sdk.com
PAY_API_KEY=your-api-keyNo NEXT_PUBLIC_ prefix
Variables prefixed with NEXT_PUBLIC_ are inlined into the browser bundle. The KEY must use an unprefixed name and be read only inside Route Handlers or Server Actions.
Install
Next.js ships Route Handlers, so no extra dependency is needed; add a chain library only if you interact directly:
bash
npm i viemMinimal working example
1. Route Handler: create the order
ts
// app/api/checkout/route.ts
import { NextResponse } from 'next/server'
export async function POST(req: Request) {
const { orderNo, amount } = await req.json()
const r = await fetch(`${process.env.API_BASE}/api/pay/open/createOrder`, {
method: 'POST',
headers: {
'X-Pay-Api-Key': process.env.PAY_API_KEY!, // server side only
'Content-Type': 'application/json',
},
body: JSON.stringify({
merchantOrderNo: orderNo, // idempotency key
chainId: 2,
tokenId: 1,
amount: String(amount), // decimal string in token units
subject: `Order ${orderNo}`,
notifyUrl: `${process.env.SITE_URL}/api/pay/notify`,
returnUrl: `${process.env.SITE_URL}/pay/done`,
}),
cache: 'no-store',
}).then((x) => x.json())
if (r.code !== 0) {
return NextResponse.json({ code: r.code, msg: r.msg }, { status: 400 })
}
const o = r.data.order
// Return only what the frontend needs
return NextResponse.json({
code: 0,
orderNo: o.orderNo,
payAddress: o.payAddress,
payAmountDisplay: o.payAmountDisplay,
payAmountMinimal: o.payAmountMinimal,
identify: o.identify,
chainKey: o.chainKey,
idempotentReplay: r.data.idempotentReplay,
})
}2. Status lookup (also server-side)
ts
// app/api/order-status/route.ts
import { NextResponse } from 'next/server'
export async function GET(req: Request) {
const orderNo = new URL(req.url).searchParams.get('orderNo')
const r = await fetch(
`${process.env.API_BASE}/api/pay/open/queryOrder?merchantOrderNo=${encodeURIComponent(orderNo!)}`,
{ headers: { 'X-Pay-Api-Key': process.env.PAY_API_KEY! }, cache: 'no-store' },
).then((x) => x.json())
if (r.code !== 0) return NextResponse.json({ code: r.code, msg: r.msg }, { status: 400 })
const o = r.data
return NextResponse.json({
code: 0,
status: o.status, // 1..6; only 3/4 mean paid
statusText: o.statusText,
txHash: o.txHash,
receivedAmountMinimal: o.receivedAmountMinimal,
})
}3. Client component for checkout
tsx
// app/checkout/Checkout.tsx
'use client'
import { useEffect, useState } from 'react'
export default function Checkout({ orderNo, amount }: { orderNo: string; amount: string }) {
const [order, setOrder] = useState<any>(null)
const [status, setStatus] = useState(1)
useEffect(() => {
fetch('/api/checkout', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ orderNo, amount }),
})
.then((r) => r.json())
.then(setOrder)
}, [orderNo, amount])
useEffect(() => {
if (!order?.orderNo) return
const t = setInterval(async () => {
const s = await fetch(`/api/order-status?orderNo=${order.orderNo}`).then((r) => r.json())
setStatus(s.status)
if (s.status === 3 || s.status === 4) clearInterval(t)
}, 3000)
return () => clearInterval(t)
}, [order])
if (!order?.orderNo) return <p>Creating order…</p>
return (
<div className="cg-qr">
<div>
<p>Transfer <b>{order.payAmountDisplay}</b> USDT (chain: {order.chainKey})</p>
<p>Address: <span className="cg-copy">{order.payAddress}</span></p>
<p>{status === 2 ? 'Transaction seen, waiting for confirmations…' : status >= 3 ? 'Payment confirmed' : 'Waiting for payment…'}</p>
</div>
</div>
)
}Callback handling
Receive callbacks in a Route Handler too. Note that you must obtain the raw body — in the App Router use await req.text(), not req.json().
ts
// app/api/pay/notify/route.ts
import { NextResponse } from 'next/server'
import crypto from 'node:crypto'
function verify(rawBody: string, ts: string, sig: string) {
const want = crypto
.createHmac('sha256', process.env.PAY_API_KEY!)
.update(ts).update(rawBody)
.digest('hex')
return crypto.timingSafeEqual(Buffer.from(want), Buffer.from(sig))
}
export async function POST(req: Request) {
const raw = await req.text() // raw string; do not use req.json()
const ts = req.headers.get('x-pay-timestamp') ?? ''
const sig = req.headers.get('x-pay-signature') ?? ''
if (!verify(raw, ts, sig)) return new NextResponse('bad signature', { status: 401 })
const p = JSON.parse(raw)
if (await isProcessed(p.merchantOrderNo)) return new NextResponse(null, { status: 200 })
await markPaid(p.merchantOrderNo, p.txHash)
return new NextResponse(null, { status: 200 }) // 2xx stops the retries
}- Idempotent: deduplicate on
merchantOrderNo. - Return fast: about a 10-second timeout; move slow logic to a queue or
after(). - Query is authoritative: verify before shipping or settling.
FAQ
| Question | Answer |
|---|---|
| Can a Route Handler hold the KEY? | Yes — it runs server-side only. This is the recommended home for it |
| Do Server Actions work? | Yes, they run on the server too and can read the environment |
What about static export (output: export)? | Then there is no server, so order creation must move to a separate server (a small Node service) |
| I can't get the raw callback body | Use await req.text(); do not req.json() and re-serialise |
| Caching makes the status stale | Add cache: 'no-store' to both the create and the query request |