Skip to content

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-key

No 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 viem

Minimal 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 ​

QuestionAnswer
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 bodyUse await req.text(); do not req.json() and re-serialise
Caching makes the status staleAdd cache: 'no-store' to both the create and the query request

Web3 payment infrastructure for global merchants