“Build a checkout form like Stripe” is a beautifully focused frontend system design question. It touches PCI compliance, iframe sandboxing, real-time validation, error handling, accessibility, and the nasty edge cases of credit card input (formatting, masking, BIN detection).
Functional requirements
- Card number, expiry, CVC, ZIP fields
- Real-time validation as user types
- Card type detection from BIN (Visa, Mastercard, Amex)
- Error messages on submit and per-field
- Submit button disabled until form is valid
- Apple Pay / Google Pay buttons where supported
The PCI scope problem
If raw card numbers touch your servers or your JavaScript, you are in PCI-DSS Level 1 scope — onerous compliance. Stripe Elements solves this by serving the card input fields from a Stripe-hosted iframe. Your page hosts the iframe; it cannot read the contents.
Architecture:
- Your page loads
stripe.js - You instantiate
cardElement = elements.create("card")and mount it - Stripe injects an iframe — the actual card input is in the iframe DOM
- On submit, you call
stripe.createToken(cardElement) - Stripe.js communicates with the iframe via postMessage, returns a token
- You send the token to your server, which charges through Stripe API
Result: your code never sees the card number. PCI scope reduces to SAQ-A.
Real-time validation
Standard Luhn check for card number. Detect card type from BIN (first 6–8 digits) — Visa starts with 4, Mastercard 5, Amex 34/37, etc.
Format on the fly: “4242 4242 4242 4242” with spaces every 4 digits. Amex uses “4-6-5” format. Update masking based on detected card type.
Validation timing
- On blur: validate after the user leaves the field
- On submit: validate the whole form
- Live during typing: only show errors after the field has been touched once
Showing errors on every keystroke is hostile. Don’t do it.
Error handling
Possible failure modes:
- Validation errors — handled inline
- Network errors — retryable, show error banner
- Card declined — server-returned, show specific message
- 3DS challenge required — Stripe handles via redirect or modal
- Fraud block — generic “transaction failed” message
Apple Pay / Google Pay
Use Payment Request API (browser standard) or platform-specific buttons. The Payment Request flow:
- Detect support (browser API exists, payment methods enrolled)
- Show button
- On tap, browser shows native payment sheet
- User authorizes
- Browser returns payment token to your JS
- Send to server, charge via Stripe
Accessibility
- Each field has
<label>properly associated - aria-invalid on error states
- aria-describedby pointing to error messages
- Focus management on submit failure (jump to first invalid field)
- Submit button announces “submitting” via aria-live
Submit flow
- Disable submit button on tap
- Show loading spinner
- Tokenize via Stripe.js
- POST token + idempotency key to your server
- Server returns success/failure
- On success: redirect to receipt page
- On failure: re-enable submit, show error
Common mistakes
- Showing all errors at once on submit (overwhelming)
- Disabling submit until all fields are valid (frustrating — user can’t test the submit handler)
- Allowing double-submit by not disabling the button
- Storing card number in a hidden field (massive PCI violation)
Frequently Asked Questions
Why use iframes instead of just a plain form?
PCI compliance. Iframes hosted by Stripe mean your page cannot access card data, dramatically reducing your audit scope.
How do you style fields inside an iframe?
Stripe Elements accepts CSS-in-JS-style configuration for fonts, colors, padding. The iframe receives the styles via the API.
Should I store cards on my server?
No — store Stripe customer/payment-method IDs. The actual card data stays at Stripe.