Build an Online Shop with Next.js #4: Checkout and Payment Simulation

Last time we finished a persistent cart on the client side. The cart lives only in the user’s browser — the server doesn’t even know it exists yet. In this post, that boundary breaks for the first time: the moment you fill out the checkout form and hit “Pay,” the cart becomes an order and crosses over to the server. Server Actions are the bridge.

The core flow:

  1. The user enters shipping info at /checkout
  2. Click “Pay” → Server Action is invoked
  3. The server runs a payment simulation (80% success / 20% failure)
  4. On success, save the order to the in-memory store and return the order ID
  5. The client clears the cart and navigates to /orders/[orderId]
  6. On failure, show the user an error and allow a retry

The payment simulation function #

First, let’s build a fake payment gateway. In a real service this is where you’d call an external payment provider’s API — something like Stripe — but at the learning stage we generate arbitrary outcomes to practice handling both branches.

src/app/lib/payments.js:

src/app/lib/payments.js
const SUCCESS_RATE = 0.8;
const NETWORK_DELAY_MS = 800;

export async function processPayment({ amount }) {
  await new Promise((resolve) => setTimeout(resolve, NETWORK_DELAY_MS));

  if (Math.random() > SUCCESS_RATE) {
    return { ok: false, error: 'Payment was declined. Please try again.' };
  }

  const transactionId = `tx_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
  return { ok: true, transactionId, amount };
}

NETWORK_DELAY_MS is an intentional delay. Real payment gateway calls don’t finish instantly, and we want our learning code to feel that too — the form’s loading state needs some time on screen to be visible at all.

SUCCESS_RATE = 0.8 is also a deliberate choice, so that we run into the failure case often. Pay five times and on average one will fail, which makes the failure UI easy to verify.

Filling in saveOrder #

Time to complete orders.js, which we only sketched in part 1. Order ID generation and storage go in.

src/app/lib/orders.js:

src/app/lib/orders.js
const orders = new Map();

export function createOrderId() {
  return `ord_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
}

export function saveOrder(order) {
  orders.set(order.id, order);
}

export function getOrder(orderId) {
  return orders.get(orderId) ?? null;
}

The ID format resembling the payment transaction ID is intentional consistency. Both combine a timestamp with a short random string — easy enough on human eyes, with a low chance of collision.

One more reminder that the store is a module-scoped Map: restart the server and every order is gone. That’s fine for the learning flow, and in the final post we’ll point out what tools to migrate to.

checkoutAction — Server Action #

We put the server action the form will call in its own file. A Server Action is an exported function from a module with 'use server' at the top.

src/app/checkout/actions.js:

src/app/checkout/actions.js
'use server';

import { createOrderId, saveOrder } from '../lib/orders';
import { processPayment } from '../lib/payments';

export async function checkoutAction(_prevState, formData) {
  const name = String(formData.get('name') ?? '').trim();
  const phone = String(formData.get('phone') ?? '').trim();
  const address = String(formData.get('address') ?? '').trim();
  const itemsJson = String(formData.get('items') ?? '[]');

  if (!name || !phone || !address) {
    return { ok: false, error: 'Please fill in all shipping fields.' };
  }

  let items;
  try {
    items = JSON.parse(itemsJson);
  } catch {
    return { ok: false, error: 'Could not read the cart data.' };
  }

  if (!Array.isArray(items) || items.length === 0) {
    return { ok: false, error: 'Your cart is empty.' };
  }

  const total = items.reduce((sum, it) => sum + it.price * it.quantity, 0);

  const payment = await processPayment({ amount: total });
  if (!payment.ok) {
    return { ok: false, error: payment.error };
  }

  const order = {
    id: createOrderId(),
    createdAt: new Date().toISOString(),
    items,
    total,
    shipping: { name, phone, address },
    status: 'paid',
  };

  saveOrder(order);

  return { ok: true, orderId: order.id };
}

Design points:

  • The useActionState signature — the first argument is the previous state, the second is FormData. The return value becomes the next state.
  • The cart travels via a hidden field — the cart is client state, so we serialize it into the form as <input type="hidden" name="items" value={JSON} /> and send it along.
  • The total is recomputed on the server — we don’t blindly trust the prices the client sent. At the learning stage we sum the prices from the same JSON, but in a real service you’d look up prices from the server’s product master and sum those. Trusting client-supplied prices opens you up to paying arbitrary amounts.
  • Validation happens on the server too — even if the client blocks empty fields, the server checks again. There is always a path that calls the form endpoint directly (e.g., curl).
  • The redirect belongs to the client — you could call redirect() inside the server action, but we handle the redirect on the client so it can be bundled with clearing the cart, which is client work. The action only returns a result object.

CheckoutForm — Client Component #

The form depends on cart state (useCart), so it’s a Client Component. We use useActionState and useFormStatus to handle the action state and the submission-in-progress state.

src/app/checkout/CheckoutForm.js:

src/app/checkout/CheckoutForm.js
'use client';

import { useActionState, useEffect } from 'react';
import { useFormStatus } from 'react-dom';
import { useRouter } from 'next/navigation';
import { useCart } from '../components/CartProvider';
import { formatPrice } from '../lib/format';
import { checkoutAction } from './actions';

const INITIAL_STATE = { ok: false, error: null, orderId: null };

export default function CheckoutForm() {
  const { items, totalPrice, clear } = useCart();
  const router = useRouter();
  const [state, formAction] = useActionState(checkoutAction, INITIAL_STATE);

  useEffect(() => {
    if (state.ok && state.orderId) {
      clear();
      router.push(`/orders/${state.orderId}`);
    }
  }, [state, clear, router]);

  return (
    <form action={formAction} className="space-y-6">
      <input type="hidden" name="items" value={JSON.stringify(items)} />

      <section>
        <h2 className="text-lg font-semibold">Shipping Information</h2>
        <div className="mt-4 space-y-3">
          <LabeledInput name="name" label="Name" autoComplete="name" />
          <LabeledInput name="phone" label="Phone" autoComplete="tel" type="tel" />
          <LabeledInput name="address" label="Address" autoComplete="street-address" />
        </div>
      </section>

      <section>
        <h2 className="text-lg font-semibold">Order Summary</h2>
        <ul className="mt-2 divide-y border-y text-sm">
          {items.map((it) => (
            <li key={it.slug} className="flex justify-between py-2">
              <span>{it.name} × {it.quantity}</span>
              <span>{formatPrice(it.price * it.quantity)}</span>
            </li>
          ))}
        </ul>
        <div className="mt-3 flex justify-between text-lg font-semibold">
          <span>Total</span>
          <span>{formatPrice(totalPrice)}</span>
        </div>
      </section>

      {state.error && (
        <div className="rounded border border-red-300 bg-red-50 px-3 py-2 text-sm text-red-700">
          {state.error}
        </div>
      )}

      <SubmitButton />
    </form>
  );
}

function LabeledInput({ name, label, type = 'text', autoComplete }) {
  return (
    <label className="block">
      <span className="block text-sm text-gray-700">{label}</span>
      <input
        name={name}
        type={type}
        autoComplete={autoComplete}
        required
        className="mt-1 w-full rounded border border-gray-300 px-3 py-2"
      />
    </label>
  );
}

function SubmitButton() {
  const { pending } = useFormStatus();
  return (
    <button
      type="submit"
      disabled={pending}
      className="w-full rounded-md bg-black px-4 py-3 text-white disabled:bg-gray-400"
    >
      {pending ? 'Processing payment…' : 'Pay'}
    </button>
  );
}

Three points are key here.

1. useActionState #

The form gets tied to the action. Pass formAction to <form action={...}> and submitting the form invokes the action. The state the action returns (state) is automatically reflected in the next render.

2. useFormStatus #

When SubmitButton sits inside the form, pending from useFormStatus() tells you whether a submission is in flight. No separate state variable needed — the code stays clean.

useFormStatus must be called inside a child component of the form. Call it in the same component and it always returns false. That’s why SubmitButton is split into its own component.

3. Post-success handling lives in useEffect #

When the server action responds with success, state.ok becomes true. We detect that change in an effect, clear the cart, and navigate to the order confirmation page.

Running router.push inside an effect may look odd, but it’s the most natural place for it. Calling redirect() inside the server action would leave no opportunity to call the client’s clear(). Bundling both in an effect, however, makes them behave like a single transaction.

The /checkout page #

The page itself is a thin wrapper.

src/app/checkout/page.js:

src/app/checkout/page.js
import CheckoutForm from './CheckoutForm';

export const metadata = {
  title: 'Checkout',
  description: 'Enter your shipping information and proceed to payment.',
};

export default function CheckoutPage() {
  return (
    <main className="mx-auto max-w-2xl px-4 py-8">
      <h1 className="text-2xl font-bold">Checkout</h1>
      <div className="mt-6">
        <CheckoutForm />
      </div>
    </main>
  );
}

The page stays a Server Component while only the form is split out as a Client Component. The page’s metadata and static markup render on the server, and only the form gets hydrated on the client. A familiar separation pattern by now.

Verify it works #

Happy path #

  1. Add a product to the cart
  2. Header “Cart” → “Proceed to Checkout” → land on /checkout
  3. Enter name/phone/address and click “Pay”
  4. The button switches to “Processing payment…” and waits about 0.8 seconds
  5. With luck (80%) you’re taken to /orders/[orderId], and the cart is cleared
  6. The cart stays empty after a refresh (localStorage was updated)

Failure path #

With bad luck (20%), a red error box appears below the form. The cart and the inputs stay intact, so retrying is just a matter of clicking “Pay” again. No re-typing, no cart wipe — the smoothest possible failure experience for the user.

Empty-field validation #

Leave the name blank and try to pay → HTML5 required blocks it. Even if the client guard somehow lets it through to the server, the server action’s empty-field check catches it again.

Hitting checkout directly with an empty cart #

You can open the /checkout URL directly without going through /cart. Click “Pay” with an empty cart and the server action blocks it with the “Your cart is empty” error. Disabling the “Pay” button on the client when items.length === 0 would be a nice touch, but the server-side guard alone is enough for data integrity.

Wrap-up #

In this post we built the boundary where the cart meets the server for the first time.

  • Isolated the external payment gateway dependency at the learning stage with a payment simulation function
  • Filled in order ID generation and in-memory storage
  • Tied validation, payment, and storage into a single flow with a Server Action
  • Handled form state and pending state with useActionState and useFormStatus
  • Handled the success/failure branches naturally from the user’s perspective (retry-friendly)
  • Put guards on both the client and the server (data integrity)

The next post is the last one. In “Build an Online Shop with Next.js #5: Order Confirmation and Deploy,” we’ll build the order lookup page, polish the SEO metadata, and deploy to Vercel so the shop is actually on the internet. We’ll wrap up with a series retrospective and a look at what you’d need to change to take this to a real production service.

X