← Blog

So baust du einen One-Page-Checkout in React und Next.js, der Ablehnungen und geänderte Preise übersteht

Ein Checkout-Formular ist einfach. Ablehnungen, 3-D Secure, Preise, die sich vor dem Bezahlen ändern, und Zahlungen ohne Antwort sind es nicht. Ein One-Page-Checkout in React und Next.js.

Preet Suthar · · 6 Min. Lesezeit

  • React
  • Next.js
  • E-commerce
  • Payments

Die meisten Checkout-Tutorials enden bei einem Formular und einem „Bezahlen“-Button, der Stripe aufruft. Dieser Teil funktioniert auf Anhieb. Bestellungen gehen durch alles drumherum verloren: die Karte, die abgelehnt wird, die Bank, die eine 3-D-Secure-Prüfung will, der Versandtarif, der sich mit der Postleitzahl ändert, die Anfrage, die nie zurückkommt, und die Kundin, die ein zweites Mal auf „Bezahlen“ drückt.

So strukturiere ich einen One-Page-Checkout in React und Next.js, mit den Zuständen, auf die es ankommt, und einem kleinen Stück Code für jeden. Das funktioniert mit shadcn/ui oder jedem anderen Komponenten-Set. Der schwierige Teil ist der Ablauf, nicht das Styling.

Eine Seite, drei einklappbare Schritte

Ein One-Page-Checkout hält alles im Blick: kein Seitenwechsel zwischen den Schritten, und der Zurück-Button des Browsers kann kein halb ausgefülltes Formular wegwerfen. Aber ein einziges langes Formular ist eine Wand. Die Lösung sind drei Schritte auf derselben Seite, Kontakt, Lieferung und Zahlung, von denen immer nur einer offen ist.

Ist ein Schritt erledigt, klappt er zu einer Übersichtszeile ein, sodass sich ein fertiger Checkout wie ein Beleg liest:

Contact   [email protected]                         Change
Ship to   12 Mission St, San Francisco, CA 94110   Change
Method    Standard · Arrives Oct 26 – 28 · Free    Change

Der Zustand dahinter ist klein. Speichere die Liste der erledigten Schritte und leite den offenen davon ab:

const steps = ["contact", "delivery", "payment"] as const
type Step = (typeof steps)[number]

function openStep(done: Step[], editing: Step | null): Step {
  return editing ?? steps.find((step) => !done.includes(step)) ?? "payment"
}

Zwei Details sorgen dafür, dass es sich richtig anfühlt. Wenn jemand auf „Ändern“ drückt, setz den Fokus auf die Überschrift des geöffneten Schritts, sonst stehen Tastatur- und Screenreader-Nutzer auf einem Button, den es nicht mehr gibt. Und wenn die Kundin angemeldet ist, überspring den Kontakt und starte bei der Lieferung mit ihrer Standardadresse.

Auf breiten Bildschirmen steht die Bestellübersicht neben dem Formular. Auf Smartphones klappt sie zu einer Leiste „Bestellübersicht anzeigen“ über dem Formular zusammen, die trotzdem die Summe zeigt, denn die Summe ist die eine Zahl, die die Leute immer wieder prüfen.

Beim Verlassen validieren, bei „Bezahlen“ zusammenfassen

Zeig den Fehler eines Felds, wenn die Kundin es verlässt, und aktualisiere ihn dann live beim Tippen, sodass er verschwindet, sobald er behoben ist. Niemals, während sie zum ersten Mal tippt.

Deaktiviere „Bezahlen“ nicht, bis das Formular gültig ist. Ein deaktivierter Button kann niemandem sagen, was fehlt. Lass die Leute ihn drücken und zeig dann oben eine Zusammenfassung, die jedes Problem auflistet, verlinke jeden Punkt mit seinem Feld und setz den Fokus auf die Zusammenfassung. Die Fehlerzusammenfassung des GOV.UK Design System ist eine gute Referenz für dieses Muster.

Tarife und Steuern folgen der Adresse

Versandtarife und Steuern hängen von der Adresse ab, also können sie erst geladen werden, wenn die Adresse vollständig ist. Warte auf eine kurze Tipp-Pause, brich die vorherige Anfrage ab, wenn sich die Adresse erneut ändert, und zeig in der Zusammenfassung „Wird berechnet…“ statt einer veralteten Zahl.

useEffect(() => {
  if (!isComplete(address)) return
  const controller = new AbortController()
  const pause = setTimeout(async () => {
    setQuote({ status: "loading" })
    try {
      const [rates, tax] = await Promise.all([
        getRates(address, controller.signal),
        estimateTax(address, controller.signal),
      ])
      setQuote({ status: "ready", rates, tax })
    } catch {
      if (!controller.signal.aborted) setQuote({ status: "error" })
    }
  }, 300)
  return () => {
    clearTimeout(pause)
    controller.abort()
  }
}, [address])

Wenn neue Tarife ankommen, behalte den gewählten Tarif, falls er noch angeboten wird. Falls nicht, wähle den günstigsten und sag es: „Express ist für diese Adresse nicht verfügbar.“ Die Versandart stillschweigend zu wechseln führt dazu, dass Leute für einen Versand zahlen, den sie nicht gewählt haben.

Express-Zahlung zuerst

Apple Pay, Google Pay, Shop Pay und PayPal ersetzen das ganze Formular, also gehören sie nach oben, über den Kontakt, mit einem Trenner „Oder mit Karte bezahlen“ darunter. Stehen sie neben dem „Bezahlen“-Button, hat die Kundin ihre Adresse schon eingetippt, wenn sie den schnelleren Weg sieht. Zeig nur Wallets an, die der Browser tatsächlich nutzen kann. Das Express Checkout Element von Stripe und ähnliche SDKs finden das für dich heraus.

„Bezahlen“ ist eine Zustandsmaschine, kein Boolean

isSubmitting kann nicht beschreiben, was nach „Bezahlen“ passiert. Gib der Antwort des Servers stattdessen einen Typ:

type PlaceOrderResult =
  | { status: "succeeded"; orderId: string }
  | { status: "declined"; code: string }
  | { status: "requires_action"; clientSecret: string }
  | { status: "changed"; changes: PriceChange[] }

Dann behandle jeden Fall ganz bewusst.

„Bezahlen“ kann nicht zweimal laufen. Ein Doppelklick löst zwei Handler aus, bevor React neu rendert, also reicht eine disabled={pending}-Prüfung nicht. Sichere mit einer Ref ab und sperre die „Ändern“-Buttons, während eine Bestellung aufgegeben wird, damit sich der Warenkorb nicht unter der Zahlung verändert.

const placing = useRef(false)

async function pay() {
  if (placing.current) return
  placing.current = true
  try {
    handleResult(await placeOrder(draft, attemptKey))
  } finally {
    placing.current = false
  }
}

Jeder Versuch bekommt einen Idempotenzschlüssel. Erzeuge ihn mit crypto.randomUUID(), wenn „Bezahlen“ gedrückt wird, und schick ihn mit der Zahlungsanfrage mit. Stripe und die meisten Zahlungs-APIs akzeptieren einen, und eine wiederholte Anfrage mit demselben Schlüssel kann nicht zweimal abbuchen.

Wer mitten in der Zahlung die Seite verlässt, wird vorher gefragt. Registriere einen beforeunload-Listener, solange eine Zahlung läuft, und entferne ihn, sobald sie abgeschlossen ist.

Ablehnungen brauchen Worte, nicht „Etwas ist schiefgelaufen“

Eine Ablehnung ist der häufigste Fehler im Checkout und der mit den schlechtesten Texten. Ordne die Codes, die du zurückbekommst, Sätzen zu, die den Leuten sagen, was sie tun sollen, setz den Fokus auf die Meldung und lass die Kartendaten ausgefüllt:

const declineCopy: Record<string, string> = {
  insufficient_funds: "Your card has insufficient funds. Try another card.",
  expired_card: "This card has expired. Check the date or use another card.",
  incorrect_cvc: "The security code doesn't match. Check it and try again.",
}

const fallbackDecline =
  "Your bank declined this payment. Try another card or contact your bank."

requires_action ist kein Fehler. Es bedeutet, dass die Bank 3-D Secure will, was in Europa im Rahmen der starken Kundenauthentifizierung Routine ist. Behalte den Verarbeitungszustand bei, öffne die Prüfung der Bank in einem Dialog mit dem Titel „Bei deiner Bank bestätigen“ und mach weiter, sobald sie abgeschlossen ist.

Wenn sich der Preis seit dem Warenkorb geändert hat

Preise, Bestand und Rabatte können sich zwischen Warenkorb und „Bezahlen“ ändern. Schick die Summe, die die Kundin gesehen hat, mit der Bestellung mit, berechne den Preis auf dem Server neu und buch nicht ab, wenn sie voneinander abweichen:

"use server"

export async function placeOrder(draft: Draft, key: string) {
  const cart = await repriceCart(draft.lines)
  if (cart.total !== draft.expectedTotal) {
    return { status: "changed", changes: diffLines(draft, cart) } as const
  }
  return chargeAndCreateOrder(cart, draft, key)
}

Aktualisiere auf dem Client die Zusammenfassung, streich die alte Summe durch und liste jede Änderung in einfachen Worten auf, etwa „Ring Vase kostet jetzt $74, vorher $68.“ Die Kundin drückt dann erneut auf „Bezahlen“, zum neuen Betrag. Ein Artikel, der während des Checkouts ausverkauft ist, funktioniert genauso: Nimm ihn aus der Summe, markiere ihn und lass sie ihn entfernen oder ohne ihn weitermachen.

Wenn die Zahlung nie antwortet

Manchmal hängt die Anfrage. Das Schlimmste, was du nach einer Zeitüberschreitung zeigen kannst, ist „Erneut versuchen“, denn die erste Abbuchung ist vielleicht durchgegangen. Zeig nach einer angemessenen Wartezeit „Zahlungsstatus wird geprüft…“ und frag einen Status-Endpunkt mit demselben Idempotenzschlüssel ab, bis du weißt, ob eine Bestellung existiert. Biete einen neuen Versuch erst an, wenn der Server sagt, dass der alte fehlgeschlagen ist.

Eine Checkliste

  • Schritte klappen zu Übersichtszeilen ein, und „Ändern“ setzt den Fokus auf den wieder geöffneten Schritt.
  • Fehler beim Verlassen des Felds, eine verlinkte Zusammenfassung bei „Bezahlen“, kein deaktivierter „Bezahlen“-Button.
  • Tarife und Steuern warten auf eine vollständige Adresse, brechen veraltete Anfragen ab und zeigen nie eine veraltete Summe.
  • Express-Wallets ganz oben, nur die, die der Browser unterstützt.
  • Ein Ref-Guard, ein Idempotenzschlüssel und eine beforeunload-Abfrage rund um „Bezahlen“.
  • Konkrete Texte pro Ablehnungscode, mit weiterhin ausgefülltem Formular.
  • 3-D Secure als Schritt, nicht als Fehler.
  • Geänderte Preise und ausverkaufte Artikel stoppen die Abbuchung und erklären sich.
  • Eine Zeitüberschreitung prüft den Status, statt erneut eine Zahlung anzubieten.

Umsetzung mit shadcn/ui

shadcn/ui liefert dir die Inputs, Radio-Gruppen und Collapsibles für all das. Der Ablauf oben ist der Teil, den du selbst schreibst, und er ist der Großteil der Arbeit.

Ich habe das einmal als Checkout-Block in HextaUI Pro gebaut, zusammengesetzt aus den kostenlosen HextaUI-Komponenten. Er nimmt den Warenkorb und drei Callbacks entgegen, onGetRates, onEstimateTax und onPlaceOrder, und behandelt die vier Ergebnisse von oben. Antwortet onPlaceOrder nicht innerhalb von 20 Sekunden, fragt er onCheckStatus mit demselben Idempotenzschlüssel ab, statt erneut eine Zahlung anzubieten. Er bringt Beispiele für Stripe PaymentIntents mit Server Actions, eine Übergabe an den Shopify-Checkout und Medusa mit. Für Wallets, Karten, Ablehnungen und 3-D Secure nutzt er den Payment-Block und für die Lieferung das Address Form. Jeder Block hat eine Live-Vorschau, durch die du dich klicken kannst, bevor du irgendetwas entscheidest.

Läuft dein Shop auf Shopify, nutz den gehosteten Checkout von Shopify, statt ihn nachzubauen. Ein eigener Checkout lohnt sich, wenn du Zahlungen selbst entgegennimmst.

Häufige Fragen

Ist ein One-Page-Checkout besser als ein mehrstufiger? Für die meisten Shops vereint eine einzige Seite mit einklappbaren Schritten das Beste aus beiden: Alles bleibt im Blick, und man hat immer nur einen Abschnitt vor sich.

Sollte ich den „Bezahlen“-Button deaktivieren, bis das Formular gültig ist? Nein. Lass die Leute ihn drücken und zeig eine Zusammenfassung dessen, was fehlt, mit Links zu jedem Feld.

Wie verhindere ich doppelte Abbuchungen? Sichere den Handler mit einer Ref ab, schick bei jedem Versuch einen Idempotenzschlüssel mit und prüfe bei einer Zeitüberschreitung den Zahlungsstatus, statt die Leute noch einmal zahlen zu lassen.

Wohin gehören Apple Pay und Google Pay? Ganz oben in den Checkout, vor die Kontaktdaten, weil sie den Rest des Formulars ersetzen.