# Stripe Elements With a Custom Card Design in React and Next.js

> Use the split card Elements, not the Payment Element, if your card preview should show the brand and flip for the CVC. Your page never sees the digits, so show dots. Track complete for number, expiry and CVC separately, style with the style object, and create the Stripe client inside the route handler so next build works without the key.

- URL: https://qodesign.dev/blog/stripe-elements-custom-card-form-react-nextjs
- Author: Teodor Avadani (https://qodesign.dev/blog/author/teodor-avadani)
- Published: 2026-10-21
- Updated: 2026-10-21
- Category: Next.js fixes
- Publisher: qodesign (https://qodesign.dev), a shop of animated React + Tailwind CSS components for Next.js, each sold with its full source code and an AI prompt.

Stripe Elements can sit inside your own card design, but the card number never reaches your code. That's the deal that keeps you out of most PCI work, and it decides what a custom card UI can show. You get the brand, whether each field is complete, and focus and blur events. You don't get the digits. So the card preview shows dots and the brand, flips when the CVC field gets focus, and your Pay button waits for Stripe to say all three fields are complete.

I make the [Credit Card Checkout](/components/credit-card-checkout-v1) piece for qodesign, a 3D card that fills in and flips for the CVC. For this post I wrote a smaller version from scratch and tested it in a fresh Next.js 16.4.2 app with React 19.3.1, Tailwind CSS 4.3.3, `@stripe/react-stripe-js` 7.0.0, `@stripe/stripe-js` 10.1.0 and `stripe` 23.0.0. Every file below passed strict `tsc` and `next build`. Then I typed into the real Stripe iframes in headless Chromium with a test-mode publishable key.

## Pick the split card Elements

Stripe has two ways to collect a card in your own page.

- **Payment Element:** one block that handles cards, wallets and Link. It's the right choice if you want Apple Pay and Google Pay with no extra work. Its change event doesn't give you the card brand or tell you when the CVC has focus, so a card preview can only show dots.
- **Split card Elements** (`CardNumberElement`, `CardExpiryElement`, `CardCvcElement`): three separate iframes you place in your own layout. The number field reports the brand, and each field has `onFocus`, `onBlur` and `onChange` with a `complete` flag.

For a card that shows the brand and turns over for the CVC, you want the split Elements. One styling detail: Stripe's [Appearance API](https://docs.stripe.com/elements/appearance-api) doesn't apply to these, and Stripe's docs say to use the `style` object instead. That's the `style` constant in the code below.

## Keep the design and the card data apart

The shape that keeps this safe is a shell component that never touches card data. It takes two props:

- `renderCardFields` renders whatever hosted fields your payment provider gives you and reports their state back through `onChange`.
- `onConfirm` runs when Pay is pressed. It gets the cardholder name and the brand, nothing else.

The shell owns the visual card, the name field and the button. If you switch providers later, the shell doesn't change.

`components/CardShell.tsx`:

```tsx
"use client";

import { useState, type ReactNode } from "react";

export type FieldState = {
  complete: boolean; // provider says number, expiry and CVC are all valid
  brand: string | null; // provider's brand word: "visa", "mastercard", "amex", "unknown"
  focus: "number" | "expiry" | "cvc" | null;
};

export type CardFieldsSlot = {
  onChange: (next: Partial<FieldState>) => void;
  fieldClassName: string;
};

type Props = {
  amountLabel: string;
  // Your provider's hosted fields go here. The shell never sees a card number.
  renderCardFields: (slot: CardFieldsSlot) => ReactNode;
  // Called on Pay once the fields are complete. Gets the name and brand only.
  onConfirm: (info: { name: string; brand: string | null }) => Promise<void>;
};

const field = "rounded-xl border border-white/15 bg-white/5 px-4 py-3 focus-within:border-lime-300";

export default function CardShell({ amountLabel, renderCardFields, onConfirm }: Props) {
  const [fields, setFields] = useState<FieldState>({ complete: false, brand: null, focus: null });
  const [name, setName] = useState("");
  const [status, setStatus] = useState<"idle" | "paying" | "paid">("idle");
  const [error, setError] = useState<string | null>(null);

  const ready = fields.complete && name.trim().length > 1 && status === "idle";
  const flipped = fields.focus === "cvc";
  const brand = fields.brand && fields.brand !== "unknown" ? fields.brand.toUpperCase() : "CARD";

  async function pay() {
    if (!ready) return;
    setStatus("paying");
    setError(null);
    try {
      await onConfirm({ name: name.trim(), brand: fields.brand });
      setStatus("paid");
    } catch (e) {
      setError(e instanceof Error ? e.message : "Payment failed");
      setStatus("idle");
    }
  }

  return (
    <div className="grid gap-8 md:grid-cols-2">
      <div className="[perspective:1000px]">
        <div
          data-testid="card"
          data-flipped={flipped}
          className={`relative aspect-[1.586] w-full transition-transform duration-500 [transform-style:preserve-3d] ${
            flipped ? "[transform:rotateY(180deg)]" : ""
          }`}
        >
          <div className="absolute inset-0 flex flex-col justify-between rounded-2xl bg-gradient-to-br from-neutral-800 to-neutral-900 p-6 [backface-visibility:hidden]">
            <span data-testid="brand" className="self-end text-sm font-bold tracking-widest text-lime-300">
              {brand}
            </span>
            <span className="font-mono text-xl tracking-widest text-white/80">•••• •••• •••• ••••</span>
            <span className="truncate text-sm uppercase text-white/70">{name || "Your name"}</span>
          </div>
          <div className="absolute inset-0 rounded-2xl bg-neutral-800 [backface-visibility:hidden] [transform:rotateY(180deg)]">
            <div className="mt-8 h-10 bg-black/70" />
            <div className="mx-6 mt-6 rounded bg-white/90 px-3 py-2 text-right font-mono text-neutral-900">•••</div>
          </div>
        </div>
      </div>

      <form
        className="flex flex-col gap-3"
        onSubmit={(e) => {
          e.preventDefault();
          void pay();
        }}
      >
        <label className="text-sm text-white/60" htmlFor="cc-name">
          Name on card
        </label>
        <input
          id="cc-name"
          autoComplete="cc-name"
          className={`${field} outline-none`}
          value={name}
          onChange={(e) => setName(e.target.value)}
        />
        {renderCardFields({ onChange: (next) => setFields((f) => ({ ...f, ...next })), fieldClassName: field })}
        <button
          type="submit"
          disabled={!ready}
          className="mt-2 rounded-xl bg-lime-300 px-4 py-3 font-semibold text-neutral-950 disabled:opacity-40"
        >
          {status === "paying" ? "Paying..." : status === "paid" ? "Paid" : `Pay ${amountLabel}`}
        </button>
        <p role="status" className="min-h-5 text-sm text-red-300">
          {error}
        </p>
      </form>
    </div>
  );
}
```

## Wire it to Stripe

`components/StripeCheckout.tsx`:

```tsx
"use client";

import { useRef } from "react";
import { loadStripe, type StripeElementStyle } from "@stripe/stripe-js";
import {
  CardCvcElement,
  CardExpiryElement,
  CardNumberElement,
  Elements,
  useElements,
  useStripe,
} from "@stripe/react-stripe-js";
import CardShell from "./CardShell";

const stripePromise = loadStripe(process.env.NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY ?? "");

// Card Elements take a style object (the Appearance API is for the Payment Element).
const style: StripeElementStyle = {
  base: {
    color: "#ffffff",
    fontSize: "16px",
    fontFamily: "system-ui, sans-serif",
    "::placeholder": { color: "#737373" },
  },
  invalid: { color: "#fca5a5" },
};

function Form({ clientSecret }: { clientSecret: string }) {
  const stripe = useStripe();
  const elements = useElements();
  // Each Element reports its own `complete`. Pay needs all three.
  const done = useRef({ number: false, expiry: false, cvc: false });

  return (
    <CardShell
      amountLabel="$29.00"
      renderCardFields={({ onChange, fieldClassName }) => {
        const mark = (part: "number" | "expiry" | "cvc", complete: boolean) => {
          done.current[part] = complete;
          const d = done.current;
          onChange({ complete: d.number && d.expiry && d.cvc });
        };
        return (
          <>
            <div className={fieldClassName}>
              <CardNumberElement
                options={{ style }}
                onChange={(e) => {
                  onChange({ brand: e.brand });
                  mark("number", e.complete);
                }}
                onFocus={() => onChange({ focus: "number" })}
              />
            </div>
            <div className="grid grid-cols-2 gap-3">
              <div className={fieldClassName}>
                <CardExpiryElement
                  options={{ style }}
                  onChange={(e) => mark("expiry", e.complete)}
                  onFocus={() => onChange({ focus: "expiry" })}
                />
              </div>
              <div className={fieldClassName}>
                <CardCvcElement
                  options={{ style }}
                  onChange={(e) => mark("cvc", e.complete)}
                  onFocus={() => onChange({ focus: "cvc" })}
                  onBlur={() => onChange({ focus: null })}
                />
              </div>
            </div>
          </>
        );
      }}
      onConfirm={async ({ name }) => {
        const card = elements?.getElement(CardNumberElement);
        if (!stripe || !card) throw new Error("Stripe is still loading");
        const { error } = await stripe.confirmCardPayment(clientSecret, {
          payment_method: { card, billing_details: { name } },
        });
        if (error) throw new Error(error.message ?? "Payment failed");
      }}
    />
  );
}

export default function StripeCheckout({ clientSecret }: { clientSecret: string }) {
  return (
    <Elements stripe={stripePromise}>
      <Form clientSecret={clientSecret} />
    </Elements>
  );
}
```

Two things in here came out of testing.

**Each Element reports its own `complete`.** My first version passed the number field's `e.complete` straight to the shell. With a valid number and a name typed, Pay unlocked while the expiry and CVC were still empty. The `done` ref fixes it: each field marks itself, and the shell only hears `complete: true` when all three are done. In the test, Pay stayed disabled with the number and name filled, stayed disabled with the expiry filled too, and only turned on after the CVC.

## Create the PaymentIntent on the server

The amount is set on the server. The browser asks for a PaymentIntent and gets back a client secret, nothing more.

`app/api/payment-intent/route.ts`:

```ts
import Stripe from "stripe";

// The price lives on the server. The browser never sends an amount or a card number.
export async function POST() {
  const key = process.env.STRIPE_SECRET_KEY;
  if (!key) return Response.json({ error: "STRIPE_SECRET_KEY is not set" }, { status: 500 });

  const stripe = new Stripe(key); // made per request, so `next build` works without the key
  const intent = await stripe.paymentIntents.create({
    amount: 2900,
    currency: "usd",
    allowed_payment_method_types: ["card"],
  });
  return Response.json({ clientSecret: intent.client_secret });
}
```

This file is where two errors showed up.

- **`next build` failed** with "Neither apiKey nor config.authenticator provided" when I created the Stripe client at the top of the file with `new Stripe(process.env.STRIPE_SECRET_KEY ?? "")`. Next.js loads route modules while it collects page data at build time, so a build machine without the secret key crashes there. Creating the client inside the handler fixed it, and the route now answers 500 with a clear message if the key is missing at runtime.
- **Strict `tsc` failed** on `payment_method_types: ["card"]`, which most tutorials still use. In `stripe` 23.0.0 that parameter isn't in the TypeScript type for `paymentIntents.create`, and the error was TS2561. The type has `allowed_payment_method_types`, so that's what the route uses. I only checked this against the package's types. The route never called Stripe's API in my test, because I didn't use a Stripe account's secret key.

And the page that ties it together:

`app/checkout/page.tsx`:

```tsx
"use client";

import { useEffect, useState } from "react";
import StripeCheckout from "@/components/StripeCheckout";

export default function CheckoutPage() {
  const [clientSecret, setClientSecret] = useState<string | null>(null);

  useEffect(() => {
    fetch("/api/payment-intent", { method: "POST" })
      .then((r) => r.json())
      .then((d: { clientSecret: string }) => setClientSecret(d.clientSecret));
  }, []);

  return (
    <main className="mx-auto max-w-4xl px-6 py-16">
      <h1 className="mb-8 text-3xl font-semibold">Checkout</h1>
      {clientSecret ? <StripeCheckout clientSecret={clientSecret} /> : <p className="text-white/60">Loading...</p>}
    </main>
  );
}
```

## What I tested

The browser test ran against a production build with a Stripe test-mode publishable key. I answered `/api/payment-intent` with a made-up client secret, since there was no secret key to create a real one.

- **Brand:** typing `5555 5555 5555 4444` showed MASTERCARD on the card. Replacing it with `4242 4242 4242 4242` showed VISA.
- **Flip:** focusing the CVC field turned the card over, and moving focus away turned it back.
- **Pay button:** disabled until the number, expiry, CVC and name were all filled, as described above.
- **No digits in your page:** after typing the full number, the page HTML didn't contain it anywhere. The number only lives inside Stripe's iframe.
- **Error path:** pressing Pay called `confirmCardPayment`, Stripe answered "No such payment_intent", the message showed under the button and the button went back to "Pay $29.00".
- **Phone width:** no sideways scroll at 390 px. No page errors.

**What I didn't test:** a successful charge, a real decline or a 3D Secure check, because those need a PaymentIntent from your own Stripe account. In test mode, try `4242 4242 4242 4242` for a success and `4000 0000 0000 0002` for a decline before you go live. Stripe's card error messages are meant for buyers (for example "Your card was declined."), which is why the shell shows `error.message` as it is.

## Common mistakes with custom card forms

- **Building your own number input and posting it to your API.** Then the card number passes through your server and your PCI scope grows a lot. The shell pattern above avoids that by design: there's no prop that could carry the number.
- **Styling Card Elements with the Appearance API.** It's for the Payment Element. Use the `style` object.
- **Trusting one `complete` flag.** Track number, expiry and CVC separately.
- **Sending the amount from the browser.** Anyone can edit it. Set it on the server when you create the PaymentIntent.

## A finished card checkout

qodesign's [Credit Card Checkout](/components/credit-card-checkout-v1) is the full version of this: a 3D card with tilt and glare, brand detection, a CVC flip and a Pay to success animation. It takes `renderCardFields` and `onConfirm` for Stripe Elements, the same pattern as above, and its README has the wiring. If you want a bigger payment moment, [Receipt Morph Checkout](/components/checkout-card) turns the card into a paper receipt stamped PAID once the payment goes through. Their setup pages are [animated credit card form for React](/react/animated-credit-card-form) and [checkout receipt animation](/react/checkout-receipt-animation). For the 3D card with Stripe's card fields already wired in and the amount set on your server, see the [Stripe Elements card form for Next.js](/react/stripe-elements-card-form-nextjs). Free pieces with full source are at [/free](/free).

## FAQ

### Can I show the card number on a custom card preview with Stripe Elements?

No. The number stays inside Stripe's iframe and your page never sees it. You can show the brand from the number field's change event and use dots for the digits. That's also why Stripe Elements keeps your site out of most PCI work.

### How do I style Stripe CardElement or CardNumberElement?

Pass a `style` object in the Element's `options`, with `base` and `invalid` keys for colour, font size, font family and placeholder colour. Stripe's Appearance API doesn't apply to the individual card Elements. Put borders, padding and focus rings on a div around the Element.

### How do I know when all Stripe card fields are complete?

Each split Element fires `onChange` with its own `complete` flag. Keep the three flags in a ref and enable Pay only when the number, expiry and CVC are all complete. The number field being complete on its own isn't enough.

### Why does next build fail with "Neither apiKey nor config.authenticator provided"?

You created the Stripe client at the top of a route file and the build environment doesn't have the secret key. Next.js loads the module while collecting page data. Create the client inside the request handler and return an error response if the key is missing.
