Checkout em SPA (React/Next.js)
O Appmax JS foi desenhado para páginas tradicionais: HTML renderizado no servidor, formulário nativo que navega, DOM estável do primeiro paint até a próxima navegação. Numa SPA nenhuma dessas premissas vale, e é aí que a integração costuma travar — quase sempre em silêncio.
Este guia mostra uma implementação completa em Next.js (App Router). Os conceitos valem para React, Vue, Svelte ou qualquer framework que monte o DOM dinamicamente.
As quatro regras
Se você guardar só isso desta página, já evita os quatro erros que consomem um dia de trabalho:
- O DOM antes do
init. Oinit()fazquerySelectoruma única vez. Gatilho de IP e formulário de cartão precisam existir no DOM antes da chamada. - O formulário fica sempre montado. Esconda com CSS, nunca com um condicional de render. Um
{visible && <form>}quebra a tokenização sem erro nenhum. - Os
namedos campos são fixos. O SDK lê comnew FormData(form), pelosname— e eles não são iguais aos valores doappmax-form-element. - O
preventDefaulté seu. Existe uma janela entre a montagem do componente e o fim doinit(), que é assíncrono. Um clique nessa janela faz o browser dar POST nativo e sair da sua aplicação.
1. Entregar o external_id ao browser
O externalId é o identificador da instalação do app naquela loja (veja external-id). Ele precisa chegar ao componente de checkout — normalmente do seu backend, junto com os dados do pedido.
// app/checkout/page.tsx (Server Component)
import CheckoutClient from './CheckoutClient';
export default async function CheckoutPage() {
const { externalId } = await loadStoreInstallation();
return <CheckoutClient externalId={externalId} />;
}Use o external_id que a sua URL de validação respondeu
O valor que vale é o que foi devolvido no corpo da resposta ao health check — é esse que a Appmax persistiu. Se o seu handler devolveu um UUID e gravou outro no banco, o checkout vai entregar um id que não existe do nosso lado, e a tokenização falha com 404 Merchant not found. Veja Implementar a URL de validação.
2. Carregar e inicializar o SDK
Um hook concentra o carregamento do script e a inicialização. Dois pontos merecem atenção: a URL do script muda por ambiente, e o init só pode rodar depois de o DOM estar montado.
// lib/useAppmax.ts
'use client';
import { useEffect, useRef } from 'react';
const SCRIPT_URL = process.env.NEXT_PUBLIC_APPMAX_SCRIPT_URL!;
// Produção: 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) {
// Guarda os callbacks em ref: o init captura o valor do momento da chamada,
// e não queremos re-inicializar a cada 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() lança exceção síncrona quando falta externalId no fluxo Apple Pay
console.error('[Appmax] falha ao inicializar', err);
}
};
document.head.appendChild(script);
return () => { script.remove(); };
}, [externalId]);
}Por que try/catch em volta do init
No fluxo Apple Pay, um externalId ausente faz o init lançar de forma síncrona, dentro da própria chamada. Sem o try/catch, isso estoura no useEffect e pode derrubar a árvore inteira.
Sobre o StrictMode
O StrictMode do React monta efeitos duas vezes em desenvolvimento. Com o bundle atual isso é inofensivo: chamar init() de novo substitui o handler do formulário em vez de empilhar, então um clique gera uma tokenização. O efeito colateral é apenas refazer a coleta de IP.
3. O gatilho da coleta de IP
A coleta é obrigatória em toda integração e acontece quando o SDK encontra um <form data-appmax-customer> no DOM. Se você já tem um formulário de dados do comprador, use-o. Se o comprador vive no estado da aplicação — o caso comum em SPA — um form vazio e escondido serve de gatilho:
<form data-appmax-customer hidden />O SDK injeta um <input type="hidden" name="ip"> dentro desse form. Ignore esse campo numa SPA: ele é escrito por DOM direto, fora do controle do React, e pode ser descartado num re-render. O valor confiável é o que chega no onIp.
4. O formulário de cartão
// 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"
// Regra 4: sem este preventDefault, um clique antes do init()
// dispara o submit nativo e o browser sai da aplicação.
onSubmit={(e) => { e.preventDefault(); onSubmit(); }}
// Regra 2: sempre montado, escondido só por CSS.
className={visible ? '' : 'hidden'}
>
<input required name="card-number" placeholder="Número do cartão" inputMode="numeric" />
<input required name="card-holder-name" placeholder="Nome impresso no cartão" />
<input required name="exp-month" placeholder="MM" inputMode="numeric" />
<input required name="exp-year" placeholder="AAAA" inputMode="numeric" />
<input required name="cvv" placeholder="CVV" inputMode="numeric" />
<button type="submit">Pagar com cartão</button>
</form>
);
}Os name são exatamente estes cinco — card-number, card-holder-name, exp-month, exp-year e cvv. Renomear qualquer um faz o campo chegar vazio na tokenização, e o erro que volta é um 422 listando os campos como field is required com a tela preenchida. A tabela completa está em Tokenização de pagamento.
O ano aceita 2 ou 4 dígitos: 31 e 2031 tokenizam igual.
Input controlado funciona
O FormData lê a propriedade value do elemento no DOM, e o React a mantém sincronizada — então um input controlado (com value e onChange) é lido normalmente. Use controlado onde você também precisa do valor no estado, como o nome do titular, que vai no payload do pagamento.
O que não funciona é value sem onChange: o React trata o input como somente leitura, o comprador não consegue digitar e o FormData lê o valor inicial.
5. Receber o token
O token chega no onTokenize, e é de uso único e vida curta — use imediatamente e não armazene.
// 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) => {
// Trava síncrona: protege contra cliques repetidos do comprador.
// Um `useState` não serve aqui — a atualização é assíncrona.
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; // libera para o comprador tentar de novo
setProcessing(false);
}
}, [ip]);
useAppmax({ externalId, onIp: setIp, onToken });
return (
<>
<form data-appmax-customer hidden />
<CreditCardForm visible={!processing} onSubmit={() => {}} />
</>
);
}A trava é por pedido, e síncrona
Com o bundle atual um clique gera uma tokenização, então a trava existe para proteger do comprador clicando duas vezes — não do SDK. Use uma ref ou variável, nunca estado de framework: a atualização do useState é assíncrona e chega tarde demais. E libere a trava quando o pagamento falhar, senão o comprador não consegue tentar de novo.
6. Efetivar o pagamento no backend
O token vai para o seu backend, que executa a sequência: criar o cliente, criar o pedido e efetivar o pagamento. As credenciais do merchant nunca chegam ao browser.
// app/api/checkout/card/route.ts
import { NextResponse } from 'next/server';
export async function POST(req: Request) {
const { token, ip } = await req.json();
// 1. Criar o cliente — o ip é obrigatório
const customer = await appmax('/v1/customers', {
firstname: 'João', lastname: 'Silva',
email: '[email protected]', telephone: '11999999999',
ip,
});
// 2. Criar o pedido
const order = await appmax('/v1/orders', {
customer_id: customer.data.id,
products: [{ sku: 'CAM-001', name: 'Camiseta', qty: 1, price: 129.9 }],
});
// 3. Efetivar o pagamento
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);
}Propague o erro da Appmax
Um catch que devolve "erro ao processar pagamento" apaga justamente a informação de que você precisa. Repasse status e corpo da resposta da Appmax para o seu log — é o que diferencia um 422 de payload de um 404 de external_id.
Os contratos completos estão em Criar cliente, Criar pedido e Pagamento com cartão de crédito.
Checklist antes de ir para produção
- [ ] A URL do script aponta para o ambiente certo (
scripts.appmax.com.brem produção,scripts.sandboxappmax.com.brem sandbox). - [ ] O
externalIdé o da instalação naquele ambiente — reinstalar em produção gera um novo. - [ ] O formulário de cartão está sempre montado, escondido por CSS.
- [ ] Os cinco
nameestão exatamente como a tabela manda. - [ ] Existe
preventDefaultpróprio noonSubmitdo formulário. - [ ] A trava contra duplo clique é síncrona (
ref, nãouseState) e é liberada no erro. - [ ] O
onErrorlogacode,stage,statusedetails. - [ ] O IP usado no
POST /v1/customersé o que chegou noonIp.
Para a tabela de sintoma → causa e o contrato completo dos callbacks, veja Appmax JS.