Documentation

Add storefront checkout

Wire Stripe and PayPal into a Next.js storefront — the base-app pattern.

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:

StepEndpointWhat it does
Read available gatewaysGET /storefront/payment-gatewaysTells you which payment methods are configured for the org
Create Stripe intentPOST /storefront/stripe/intentMints a client_secret and persists a draft order
Get PayPal configGET /storefront/paypal/configReturns 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. 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 the myAccount.layout page 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. 2

    Fetch available payment gateways

    base-app/src/app/checkout/page.tsx does 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} />;
    }
    

    paymentGateways looks like { stripe: { enabled: true, publishableKey: 'pk_...' }, paypal: { enabled: true, clientId: '...' } }. Branch the UI off this object — never hardcode.

  3. 3

    Render the Stripe form

    The @stripe/react-stripe-js package needs a client secret from your backend. The storefront/stripe/intent endpoint 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 confirmPayment call sends the card to Stripe directly. If 3-D Secure is required, Stripe redirects the browser; on return Stripe hits your return_url, which loads /checkout/success.

    The order has already been created in AppEngine — payment.succeeded events from the Stripe webhook flip its state.

  4. 4

    Wire the AppEngine intent helper

    The storefrontAPI.createStripeIntent is 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 order record with state: '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. 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 createPaypalOrder and capturePaypalOrder post to AppEngine — storefront/paypal/create and storefront/paypal/capture. AppEngine talks to PayPal's REST API server-side. Same pattern as Stripe: the browser never holds the secret.

  6. 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. 7

    Handle webhooks

    Stripe and PayPal both send asynchronous webhooks. AppEngine has built-in webhook receivers — /webhooks/stripe and /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. 8

    Test in dev

    Stripe ships test cards. Use 4242 4242 4242 4242 with any future expiry and any CVC. For 3-D Secure flow, use 4000 0027 6000 3184.

    For PayPal, use the sandbox client id and the sandbox business account. AppEngine reads its own STRIPE_MODE=test and PAYPAL_MODE=sandbox envs to swap to test endpoints.

    Once webhooks fire, you'll see the order state flip in the admin UI. If it doesn't, check:

    1. AppEngine logs (/api/monitoring/health) for webhook receipt
    2. Stripe dashboard → Webhooks → recent deliveries
    3. The automation-events collection for the workflow trace

What's next