Skip to content

SPA checkout (React/Next.js) ​

Appmax JS was designed for traditional pages: server-rendered HTML, a native form that navigates away, a DOM that stays stable from first paint until the next navigation. In an SPA none of those assumptions hold, and that's where the integration usually stalls — almost always silently.

This guide shows a complete implementation in Next.js (App Router). The concepts apply to React, Vue, Svelte or any framework that builds the DOM dynamically.

The four rules ​

If you take only this away from this page, you'll avoid the four mistakes that cost a day of work:

  1. DOM before init. init() runs querySelector exactly once. The IP trigger and the card form must exist in the DOM before the call.
  2. The form stays mounted. Hide it with CSS, never with a render conditional. A {visible && <form>} breaks tokenization with no error at all.
  3. Field name attributes are fixed. The SDK reads with new FormData(form), by name — and they are not the same as the appmax-form-element values.
  4. preventDefault is yours. There is a window between the component mounting and init() finishing, since it is async. A click in that window makes the browser do a native POST and leave your app.

1. Deliver external_id to the browser ​

externalId identifies the app installation in that store (see external-id). It has to reach the checkout component — normally from your backend, along with the order data.

ts
// app/checkout/page.tsx  (Server Component)
import CheckoutClient from './CheckoutClient';

export default async function CheckoutPage() {
  const { externalId } = await loadStoreInstallation();

  return <CheckoutClient externalId={externalId} />;
}

Use the external_id your validation URL responded with

The value that counts is the one returned in the health check response body — that's the one Appmax persisted. If your handler returned one UUID and stored another in its database, the checkout will hand over an id that doesn't exist on our side, and tokenization fails with 404 Merchant not found. See Implementing the validation URL.

2. Load and initialize the SDK ​

A hook concentrates script loading and initialization. Two points deserve attention: the script URL changes per environment, and init can only run once the DOM is mounted.

ts
// lib/useAppmax.ts
'use client';

import { useEffect, useRef } from 'react';

const SCRIPT_URL = process.env.NEXT_PUBLIC_APPMAX_SCRIPT_URL!;
// Production: https://scripts.appmax.com.br/appmax.min.js
// Sandbox:    https://scripts.sandboxappmax.com.br/appmax.min.js

type Options = {
  externalId: string;
  onIp: (ip: string) => void;
  onToken: (token: string) => void;
};

export function useAppmax({ externalId, onIp, onToken }: Options) {
  // Keep callbacks in a ref: init captures the value at call time,
  // and we don't want to re-initialize on every render.
  const handlers = useRef({ onIp, onToken });
  handlers.current = { onIp, onToken };

  useEffect(() => {
    if (!externalId) return;

    const script = document.createElement('script');
    script.src = SCRIPT_URL;
    script.async = true;

    script.onload = () => {
      try {
        window.AppmaxScripts.init({
          externalId,
          onIp: ({ ip }) => handlers.current.onIp(ip),
          onTokenize: ({ token }) => handlers.current.onToken(token),
          onError: (err) => {
            console.error('[Appmax]', err.code, err.stage, err.status, err.details);
          },
        });
      } catch (err) {
        // init() throws synchronously when externalId is missing in the Apple Pay flow
        console.error('[Appmax] failed to initialize', err);
      }
    };

    document.head.appendChild(script);
    return () => { script.remove(); };
  }, [externalId]);
}

Why try/catch around init

In the Apple Pay flow, a missing externalId makes init throw synchronously, inside the call itself. Without the try/catch, that blows up in useEffect and can take down the whole tree.

About StrictMode

React StrictMode mounts effects twice in development. With the current bundle this is harmless: calling init() again replaces the form handler instead of stacking, so one click produces one tokenization. The only side effect is redoing IP collection.

3. The IP collection trigger ​

Collection is mandatory in every integration and happens when the SDK finds a <form data-appmax-customer> in the DOM. If you already have a customer data form, use it. If the customer lives in application state — the common case in an SPA — an empty, hidden form works as the trigger:

tsx
<form data-appmax-customer hidden />

The SDK injects an <input type="hidden" name="ip"> into that form. Ignore that field in an SPA: it is written through direct DOM manipulation, outside React's control, and can be dropped on a re-render. The dependable value is the one that arrives in onIp.

4. The card form ​

tsx
// app/checkout/CreditCardForm.tsx
'use client';

type Props = {
  visible: boolean;
  onSubmit: () => void;
};

export default function CreditCardForm({ visible, onSubmit }: Props) {
  return (
    <form
      data-appmax-checkout
      method="POST"
      // Rule 4: without this preventDefault, a click before init()
      // fires the native submit and the browser leaves the app.
      onSubmit={(e) => { e.preventDefault(); onSubmit(); }}
      // Rule 2: always mounted, hidden with CSS only.
      className={visible ? '' : 'hidden'}
    >
      <input required name="card-number"      placeholder="Card number" inputMode="numeric" />
      <input required name="card-holder-name" placeholder="Name on card" />
      <input required name="exp-month"        placeholder="MM"   inputMode="numeric" />
      <input required name="exp-year"         placeholder="YYYY" inputMode="numeric" />
      <input required name="cvv"              placeholder="CVV"  inputMode="numeric" />
      <button type="submit">Pay with card</button>
    </form>
  );
}

The name attributes are exactly these five — card-number, card-holder-name, exp-month, exp-year and cvv. Renaming any of them makes that field arrive empty at tokenization, and the error you get back is a 422 listing the fields as field is required with the form filled in. The full table is in Payment tokenization.

The year accepts 2 or 4 digits: 31 and 2031 tokenize identically.

Controlled inputs work

FormData reads the DOM element's value property, and React keeps it in sync — so a controlled input (with value and onChange) is read normally. Use controlled where you also need the value in state, such as the cardholder name, which goes in the payment payload.

What doesn't work is value without onChange: React treats the input as read-only, the shopper can't type, and FormData reads the initial value.

5. Receive the token ​

The token arrives in onTokenize, and it is single-use and short-lived — use it immediately and don't store it.

tsx
// app/checkout/CheckoutClient.tsx
'use client';

import { useCallback, useRef, useState } from 'react';
import { useAppmax } from '@/lib/useAppmax';
import CreditCardForm from './CreditCardForm';

export default function CheckoutClient({ externalId }: { externalId: string }) {
  const [ip, setIp] = useState<string>('');
  const [processing, setProcessing] = useState(false);
  const inFlight = useRef(false);

  const onToken = useCallback(async (token: string) => {
    // Synchronous lock: protects against the shopper clicking repeatedly.
    // A `useState` won't do here — the update is asynchronous.
    if (inFlight.current) return;
    inFlight.current = true;
    setProcessing(true);

    try {
      const res = await fetch('/api/checkout/card', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ token, ip }),
      });
      if (!res.ok) throw new Error((await res.json()).message);
      window.location.href = '/checkout/success';
    } catch (err) {
      showError(err);
      inFlight.current = false;   // release so the shopper can retry
      setProcessing(false);
    }
  }, [ip]);

  useAppmax({ externalId, onIp: setIp, onToken });

  return (
    <>
      <form data-appmax-customer hidden />
      <CreditCardForm visible={!processing} onSubmit={() => {}} />
    </>
  );
}

The lock is per order, and synchronous

With the current bundle one click produces one tokenization, so the lock exists to protect against the shopper double-clicking — not against the SDK. Use a ref or a plain variable, never framework state: the useState update is asynchronous and arrives too late. And release the lock when the payment fails, or the shopper can't try again.

6. Complete the payment on the backend ​

The token goes to your backend, which runs the sequence: create the customer, create the order and complete the payment. Merchant credentials never reach the browser.

ts
// app/api/checkout/card/route.ts
import { NextResponse } from 'next/server';

export async function POST(req: Request) {
  const { token, ip } = await req.json();

  // 1. Create the customer — ip is required
  const customer = await appmax('/v1/customers', {
    firstname: 'John', lastname: 'Doe',
    email: '[email protected]', telephone: '11999999999',
    ip,
  });

  // 2. Create the order
  const order = await appmax('/v1/orders', {
    customer_id: customer.data.id,
    products: [{ sku: 'TSH-001', name: 'T-shirt', qty: 1, price: 129.9 }],
  });

  // 3. Complete the payment
  const payment = await appmax('/v1/payments/credit-card', {
    cart: { order_id: order.data.id },
    customer: { customer_id: customer.data.id },
    payment: {
      creditcard: {
        card_token: token,
        installments: 1,
      },
    },
  });

  return NextResponse.json(payment);
}

Propagate Appmax's error

A catch that returns "error processing payment" erases exactly the information you need. Pass Appmax's response status and body through to your logs — that is what tells a payload 422 from an external_id 404.

The full contracts live in Create customer, Create order and Credit card payment.

Checklist before going to production ​

  • [ ] The script URL points at the right environment (scripts.appmax.com.br in production, scripts.sandboxappmax.com.br in sandbox).
  • [ ] externalId is the installation's for that environment — reinstalling in production generates a new one.
  • [ ] The card form is always mounted, hidden with CSS.
  • [ ] All five name attributes match the table exactly.
  • [ ] The form's onSubmit has your own preventDefault.
  • [ ] The double-click lock is synchronous (ref, not useState) and is released on error.
  • [ ] onError logs code, stage, status and details.
  • [ ] The IP used in POST /v1/customers is the one that arrived in onIp.

For the symptom → cause table and the full callback contract, see Appmax JS.