Checkout is the single most error-prone surface in an e-commerce app. AppEngine pushes the heavy logic — pricing, tax, shipping, payment intent — onto the server so your Next.js code stays small. This walkthrough turns a plain product list into a working Stripe + PayPal checkout, mirroring base-app/src/app/checkout/.
The flow
cart → checkout page → payment-method picker → intent → 3-D Secure → success
Three AppEngine endpoints carry it:
| Step | Endpoint | What it does |
|---|---|---|
| Read available gateways | GET /storefront/payment-gateways | Tells you which payment methods are configured for the org |
| Create Stripe intent | POST /storefront/stripe/intent | Mints a client_secret and persists a draft order |
| Get PayPal config | GET /storefront/paypal/config | Returns the public client id and currency |
Plus the cart endpoints from the cart guide: storefront/checkout-cart, storefront/checkout-buy-now, storefront/discounts/apply.
Prerequisites
- Working AppEngine integration with auth (Add auth tutorial)
- Stripe and/or PayPal account with API keys, configured in AppEngine Settings → Payment gateways
- A working cart — see base-app's cart store
Step-by-step
- 1
Build the checkout layout
Borrow the layout pattern from
base-app/src/app/checkout/layout.tsx. The site object decides whether the org has e-commerce enabled, and themyAccount.layoutpage id provides the wrapping chrome.// src/app/checkout/layout.tsx import { headers } from 'next/headers'; import { getAppEngineClient } from '@/lib/appmint-client'; export default async function CheckoutLayout({ children }: { children: React.ReactNode }) { const headersList = await headers(); const hostName = headersList.get('host') || ''; const site = await getAppEngineClient().getSite(hostName); if (!site?.data?.features?.includes('e-commerce')) { return <div>Checkout is not enabled.</div>; } return <div className="mx-auto max-w-3xl py-8">{children}</div>; } - 2
Fetch available payment gateways
base-app/src/app/checkout/page.tsxdoes this on the server so the client renders only the gateways the org actually has keys for.// src/app/checkout/page.tsx import { getAppEngineClient } from '@/lib/appmint-client'; import { API_ENDPOINTS } from '@/lib/appmint-endpoints'; import { Checkout } from '@/components/Checkout'; export default async function CheckoutPage() { const client = getAppEngineClient(); const paymentGateways = await client.processRequest( 'get', API_ENDPOINTS.STORE_CHECKOUT_PAYMENT_GATEWAYS, ); return <Checkout paymentGateways={paymentGateways} />; }paymentGatewayslooks like{ stripe: { enabled: true, publishableKey: 'pk_...' }, paypal: { enabled: true, clientId: '...' } }. Branch the UI off this object — never hardcode. - 3
Render the Stripe form
The
@stripe/react-stripe-jspackage needs a client secret from your backend. Thestorefront/stripe/intentendpoint mints one and persists a draft order in the same call.// src/components/StripeCheckout.tsx 'use client'; import { useEffect, useState } from 'react'; import { Elements, PaymentElement, useStripe, useElements } from '@stripe/react-stripe-js'; import { loadStripe } from '@stripe/stripe-js'; import { storefrontAPI } from '@/lib/storefront-api'; export function StripeCheckout({ publishableKey, cart, customer }: Props) { const [clientSecret, setClientSecret] = useState<string | null>(null); const [orderId, setOrderId] = useState<string | null>(null); useEffect(() => { storefrontAPI.createStripeIntent({ cartId: cart.id, customer }).then((res) => { setClientSecret(res.clientSecret); setOrderId(res.orderId); }); }, [cart.id]); if (!clientSecret) return <p>Preparing payment...</p>; const stripePromise = loadStripe(publishableKey); return ( <Elements stripe={stripePromise} options={{ clientSecret }}> <PaymentForm orderId={orderId!} /> </Elements> ); } function PaymentForm({ orderId }: { orderId: string }) { const stripe = useStripe(); const elements = useElements(); const [submitting, setSubmitting] = useState(false); async function onSubmit(e: React.FormEvent) { e.preventDefault(); if (!stripe || !elements) return; setSubmitting(true); const { error } = await stripe.confirmPayment({ elements, confirmParams: { return_url: `${window.location.origin}/checkout/success?order=${orderId}`, }, }); if (error) alert(error.message); setSubmitting(false); } return ( <form onSubmit={onSubmit} className="space-y-4"> <PaymentElement /> <button type="submit" disabled={!stripe || submitting} className="w-full rounded bg-blue-600 py-2 font-medium text-white" > {submitting ? 'Processing...' : 'Pay'} </button> </form> ); }The
confirmPaymentcall sends the card to Stripe directly. If 3-D Secure is required, Stripe redirects the browser; on return Stripe hits yourreturn_url, which loads/checkout/success.The order has already been created in AppEngine —
payment.succeededevents from the Stripe webhook flip its state. - 4
Wire the AppEngine intent helper
The
storefrontAPI.createStripeIntentis a thin wrapper:// src/lib/storefront-api.ts (excerpt) async createStripeIntent({ cartId, customer, returnUrl }: CreateIntentArgs) { const client = getAppEngineClient(); return client.processRequest('post', 'storefront/stripe/intent', { cartId, customer, returnUrl, }); }AppEngine handles:
- Calculating tax (via the tax module)
- Applying loyalty + group discounts
- Calling Stripe to create the PaymentIntent
- Persisting a draft
orderrecord withstate: 'pending-payment' - Returning
{ clientSecret, orderId, total }
You don't ship the Stripe secret key to the browser, ever. It lives only in AppEngine's env.
- 5
PayPal flow
PayPal works differently — the SDK script needs the client id, and the approval happens in their popup.
// src/components/PaypalCheckout.tsx 'use client'; import { PayPalScriptProvider, PayPalButtons } from '@paypal/react-paypal-js'; import { storefrontAPI } from '@/lib/storefront-api'; export function PaypalCheckout({ clientId, currency, cart }: Props) { return ( <PayPalScriptProvider options={{ clientId, currency }}> <PayPalButtons createOrder={async () => { const res = await storefrontAPI.createPaypalOrder({ cartId: cart.id }); return res.id; }} onApprove={async (data) => { const res = await storefrontAPI.capturePaypalOrder({ paypalOrderId: data.orderID }); if (res.success) window.location.href = `/checkout/success?order=${res.orderId}`; }} /> </PayPalScriptProvider> ); }Both
createPaypalOrderandcapturePaypalOrderpost to AppEngine —storefront/paypal/createandstorefront/paypal/capture. AppEngine talks to PayPal's REST API server-side. Same pattern as Stripe: the browser never holds the secret. - 6
Build the success page
base-app/src/app/checkout/success/reads the order id from the query string, fetches the order, and renders the confirmation. Crucially: don't trust the URL alone. Verify the order belongs to the authenticated user.// src/app/checkout/success/page.tsx import { headers } from 'next/headers'; import { getAppEngineClient } from '@/lib/appmint-client'; export default async function SuccessPage({ searchParams, }: { searchParams: Promise<{ order?: string }>; }) { const { order: orderId } = await searchParams; if (!orderId) return <div>Order not found</div>; const order = await getAppEngineClient().processRequest('get', `data/order/${orderId}`); if (order.data.state === 'pending-payment') { return <div>Your payment is processing. You'll get an email shortly.</div>; } return ( <div className="mx-auto max-w-lg py-12 text-center"> <h1 className="text-2xl font-semibold">Thanks for your order</h1> <p className="mt-2 text-gray-600">Order #{order.data.orderNumber}</p> <p className="mt-2">Total: ${order.data.total.toFixed(2)}</p> </div> ); }The auth middleware ensures only the buyer (or an admin) can read the order. AppEngine returns 403 otherwise.
- 7
Handle webhooks
Stripe and PayPal both send asynchronous webhooks. AppEngine has built-in webhook receivers —
/webhooks/stripeand/webhooks/paypal. Configure the webhook URLs in your Stripe + PayPal dashboards to point at AppEngine. Don't forward webhooks through your Next.js app.The webhooks update order state (
state: 'paid','refunded','disputed') and fire workflow events the rest of the system listens for: tax filing, fulfillment, accounting. - 8
Test in dev
Stripe ships test cards. Use
4242 4242 4242 4242with any future expiry and any CVC. For 3-D Secure flow, use4000 0027 6000 3184.For PayPal, use the sandbox client id and the sandbox business account. AppEngine reads its own
STRIPE_MODE=testandPAYPAL_MODE=sandboxenvs to swap to test endpoints.Once webhooks fire, you'll see the order state flip in the admin UI. If it doesn't, check:
- AppEngine logs (
/api/monitoring/health) for webhook receipt - Stripe dashboard → Webhooks → recent deliveries
- The
automation-eventscollection for the workflow trace
- AppEngine logs (
What's next
- Cart and discounts — pricing tiers, coupons, group benefits.
- Orders and fulfillment — what happens after the payment lands.
- Track affiliate conversions — attach affiliate codes to orders so commissions calculate automatically.