OWolf

BlogToolsProjectsAboutContact
© 2025 owolf.com
HomeAboutNotesContactPrivacy

2025-07-05 Web Development, Ecommerce

Stripe + Next.js 15 Test App: Step-by-Step Walkthrough

By O. Wolfson

This guide will walk you through creating a complete test payment app with Stripe and Next.js 15 (App Router), including solutions to common integration questions and gotchas. You’ll build a secure test workflow, see key Stripe/React concepts in action, and understand why each step is important for a smooth dev experience.


Table of Contents

  1. Overview
  2. Prerequisites
  3. Get Stripe Test Keys
  4. Set Up Next.js Project
  5. Environment Variables
  6. Install Stripe Packages
  7. Create the Stripe API Route
  8. Create the Stripe Elements Provider
  9. Build the Checkout Form
  10. Connect Components
  11. Test with Stripe Test Cards
  12. Troubleshooting & Gotchas
  13. Extras: Customization & Advanced Tips
  14. Appendix: Example Project Structure
  15. Summary

1. Overview

This walkthrough shows how to build a working Stripe payment demo in Next.js 15 (with the App Router), using secure best practices, and answers common beginner questions along the way.


2. Prerequisites

  • Node.js 18+
  • Stripe account (free)
  • Familiarity with React basics

3. Get Stripe Test Keys

  1. Log in to your Stripe Dashboard.

  2. Enable View test data at the top/side toggle.

  3. Go to Developers → API keys.

  4. Copy your keys:

    • Secret key: sk_test_... (for backend only)
    • Publishable key: pk_test_... (for frontend)

Note: Never share your secret key. Don’t mix up sk_ and pk_!


4. Set Up Next.js Project

bash
npx create-next-app@latest stripe-next-test
cd stripe-next-test

If asked, use the App Router option.


5. Environment Variables

Create a .env.local file at the project root:

env
STRIPE_SECRET_KEY=sk_test_XXXXXXXXXXXXXXXXXX
NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=pk_test_XXXXXXXXXXXXXXXXX
  • Don’t commit this file!
  • Publishable key (with NEXT_PUBLIC_) is safe for frontend. Secret key is only for server.

6. Install Stripe Packages

bash
npm install stripe @stripe/stripe-js @stripe/react-stripe-js

7. Create the Stripe API Route

File: app/api/create-payment-intent/route.js or .ts

js
import { NextResponse } from "next/server";
import Stripe from "stripe";

const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);

export async function POST(request: Request) {
  try {
    const body = await request.json();
    const { amount } = body;

    const paymentIntent = await stripe.paymentIntents.create({
      amount, // amount in cents
      currency: "usd",
    });

    return NextResponse.json({ clientSecret: paymentIntent.client_secret });
  } catch (err: unknown) {
    if (err instanceof Error) {
      return NextResponse.json({ error: err.message }, { status: 400 });
    }
    return NextResponse.json(
      { error: "An unknown error occurred" },
      { status: 400 }
    );
  }
}

8. Create the Stripe Elements Provider

File: src/components/stripe-elements-provider.tsx

tsx
"use client";
import { Elements } from "@stripe/react-stripe-js";
import { loadStripe } from "@stripe/stripe-js";

const stripePromise = loadStripe(
  process.env.NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY!
);

export default function StripeElementsProvider({
  children,
}: {
  children: React.ReactNode;
}) {
  return <Elements stripe={stripePromise}>{children}</Elements>;
}
  • This provider must be implemented as a "use client" component. However, in Next.js App Router, you may use it from a Server Component (such as a page or route). Next.js will ensure that the Elements provider and its children run on the client as needed.
  • Best practice: Do NOT use this provider globally in layout.tsx. Instead, use it in your payment page (e.g., app/checkout/page.tsx), wrapping only the components that need Stripe context.

9. Build the Checkout Form

File: src/components/checkout-form.tsx

tsx
"use client";
import { useState } from "react";
import { useStripe, useElements, CardElement } from "@stripe/react-stripe-js";

export default function CheckoutForm() {
  const stripe = useStripe();
  const elements = useElements();
  const [loading, setLoading] = useState(false);
  const [message, setMessage] = useState("");

  const handleSubmit = async (e: React.FormEvent) => {
    e.preventDefault();
    setLoading(true);
    setMessage("");

    const res = await fetch("/api/create-payment-intent", {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify({ amount: 1000 }), // $10
    });
    const { clientSecret, error } = await res.json();
    if (error) {
      setMessage(error);
      setLoading(false);
      return;
    }
    if (!stripe || !elements) {
      setMessage("Stripe.js has not loaded yet.");
      setLoading(false);
      return;
    }
    const result = await stripe.confirmCardPayment(clientSecret, {
      payment_method: { card: elements.getElement(CardElement)! },
    });
    if (result.error) {
      setMessage(result.error.message ?? "An error occurred");
    } else if (result.paymentIntent.status === "succeeded") {
      setMessage("Payment successful!");
    } else {
      setMessage("Something went wrong.");
    }
    setLoading(false);
  };

  return (
    <form onSubmit={handleSubmit}>
      <CardElement />
      <button type="submit" disabled={!stripe || loading}>
        {loading ? "Processing..." : "Pay $10"}
      </button>
      {message && <div>{message}</div>}
    </form>
  );
}
  • Why is the button disabled? Until Stripe.js loads, the button stays disabled for security.
  • Why only one input box? <CardElement /> combines card number, expiry, and CVC into a single smart input field. Enter all details in sequence!
  • Common error: “Your card’s expiration date is incomplete.” This just means you need to finish all fields inside <CardElement />.

10. Connect Components

File: app/checkout/page.tsx

tsx
import StripeElementsProvider from "@/components/stripe-elements-provider";
import CheckoutForm from "@/components/checkout-form";

export default function CheckoutPage() {
  return (
    <StripeElementsProvider>
      <CheckoutForm />
    </StripeElementsProvider>
  );
}

Do NOT wrap your whole app in <Elements> in layout.tsx—that’s a Server Component! Always wrap payment pages in a client provider.


11. Test with Stripe Test Cards

  • Card Number: 4242 4242 4242 4242
  • Expiry: Any future date (e.g., 12/34)
  • CVC: Any 3 digits (e.g., 123)
  • ZIP: Any 5 digits (e.g., 12345)

Input all details in the single CardElement input. Press Tab to move between sub-fields. Use Stripe’s test cards for more scenarios.


12. Troubleshooting & Gotchas

  • Button stays disabled: You likely swapped secret/publishable keys or Stripe.js hasn't loaded. Double check your .env.local.
  • Don’t see card input? Make sure <CardElement /> is in a Client Component, inside <Elements>.
  • Got a “createContext only works in Client Components” error? Don’t put Stripe context providers in layout.tsx—only in Client Components!
  • “Expiration date is incomplete” error? Just finish filling out the CardElement’s fields.
  • Changes to .env.local not working? Always restart the dev server after editing env files.

13. Extras: Customization & Advanced Tips

  • To split card fields, use <CardNumberElement />, <CardExpiryElement />, etc., from Stripe React SDK.
  • To style CardElement, pass an options prop with a style object.
  • Use the Stripe CLI to test webhooks locally for real-world payment flows.

Next steps:

  • Add webhooks to handle post-payment actions
  • Add error boundary and advanced error UX
  • Deploy to Vercel (update Stripe keys to live mode for production)

14. Appendix: Example Project Structure

Here’s how your Stripe + Next.js 15 project structure might look. This structure keeps code organized and makes it easy to follow the guide:

text
stripe-next-test/
├── app/
│   ├── api/
│   │   └── create-payment-intent/
│   │       └── route.js        # API route for PaymentIntent (step 7)
│   ├── checkout/
│   │   └── page.tsx           # Payment page (step 10)
│   └── layout.tsx             # App layout (should NOT include Stripe provider)
│   └── page.tsx               # Home/landing page (can link to /checkout)
├── src/
│   └── components/
│       ├── checkout-form.tsx          # Checkout form UI (step 9)
│       └── stripe-elements-provider.tsx # Elements provider (step 8)
├── .env.local                # Secret & publishable keys (step 5)
├── package.json
└── ...
  • Adjust src/ and app/ split as you prefer for your own project (Next.js supports both top-level app/ and colocated src/app/).
  • All payment logic, API, and Stripe context is encapsulated in specific files. No secret key or Stripe context in global layout.

Summary

You now have a full Stripe test payment flow in Next.js 15 App Router, with:

  • Secure API route for backend logic
  • Secure key management
  • Card form with <CardElement />
  • Best practices and solutions for common Stripe/Next.js integration questions

Here are the official Stripe documentation links for their UI components and payments APIs:


1. Stripe Elements (Custom Forms)

  • Stripe Elements Overview
  • Elements Appearance API (Styling)
  • Elements Quickstart Guide

2. Stripe Checkout (Hosted Page)

  • Stripe Checkout Overview
  • Styling and Branding Checkout

3. Stripe.js (Front-end SDK)

  • Stripe.js Reference

4. Stripe API Reference

  • Stripe API Docs
  • Node.js Library Docs

5. Stripe React Integration

  • React Stripe.js Docs

Bookmark:

  • All Stripe Docs Home