July 5, 2025
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.
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.
Log in to your Stripe Dashboard.
Enable View test data at the top/side toggle.
Go to Developers → API keys.
Copy your keys:
sk_test_... (for backend only)pk_test_... (for frontend)Note: Never share your secret key. Don’t mix up sk_ and pk_!
bashnpx create-next-app@latest stripe-next-test
cd stripe-next-test
If asked, use the App Router option.
Create a .env.local file at the project root:
envSTRIPE_SECRET_KEY=sk_test_XXXXXXXXXXXXXXXXXX NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=pk_test_XXXXXXXXXXXXXXXXX
NEXT_PUBLIC_) is safe for frontend. Secret key is only for server.bashnpm install stripe @stripe/stripe-js @stripe/react-stripe-js
File: app/api/create-payment-intent/route.js or .ts
jsimport { 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 }
);
}
}
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>;
}
"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.layout.tsx. Instead, use it in your payment page (e.g., app/checkout/page.tsx), wrapping only the components that need Stripe context.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);
;
}
(!stripe || !elements) {
();
();
;
}
result = stripe.(clientSecret, {
: { : elements.()! },
});
(result.) {
(result.. ?? );
} (result.. === ) {
();
} {
();
}
();
};
(
);
}
<CardElement /> combines card number, expiry, and CVC into a single smart input field. Enter all details in sequence!<CardElement />.File: app/checkout/page.tsx
tsximport 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.
4242 4242 4242 424212/34)123)12345)Input all details in the single CardElement input. Press Tab to move between sub-fields. Use Stripe’s test cards for more scenarios.
.env.local.<CardElement /> is in a Client Component, inside <Elements>.layout.tsx—only in Client Components!<CardNumberElement />, <CardExpiryElement />, etc., from Stripe React SDK.options prop with a style object.Next steps:
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:
textstripe-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 └── ...
src/ and app/ split as you prefer for your own project (Next.js supports both top-level app/ and colocated src/app/).You now have a full Stripe test payment flow in Next.js 15 App Router, with:
<CardElement />Here are the official Stripe documentation links for their UI components and payments APIs:
Bookmark:
Why not using server actions?
I was thinking this could be used as an api, but in an actual implementation I might well use server actions.