TL;DR 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.
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 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.
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 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.
Credit Card Checkout V1 running live. A live 3D card that fills in as you type and flips for the CVC.
"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.
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.
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.
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.
qodesign's Credit Card Checkout 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 turns the card into a paper receipt stamped PAID once the payment goes through. Their setup pages are animated credit card form for React and 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. Free pieces with full source are at /free.
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.
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.
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.
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.