From 92102dcafbaff99c7bc921273291342ef5da0d89 Mon Sep 17 00:00:00 2001 From: Shahed Nasser Date: Tue, 16 Sep 2025 16:17:07 +0300 Subject: [PATCH] docs: add Next.js starter guides to llms-full.txt (#13523) --- www/apps/book/public/llms-full.txt | 1283 ++++++++++++++++++++++++++++ www/apps/book/scripts/prepare.mjs | 10 + 2 files changed, 1293 insertions(+) diff --git a/www/apps/book/public/llms-full.txt b/www/apps/book/public/llms-full.txt index 6a18969746..84eb76afad 100644 --- a/www/apps/book/public/llms-full.txt +++ b/www/apps/book/public/llms-full.txt @@ -96089,6 +96089,1289 @@ The following operators are supported by the service factory filtering mechanism |\`$or\`|Joins two or more conditions with a logical OR.| +# Use Stripe's Payment Element in the Next.js Starter Storefront + +In this tutorial, you'll learn how to customize the Next.js Starter Storefront to use [Stripe's Payment Element](https://docs.stripe.com/payments/payment-element). + +By default, the Next.js Starter Storefront comes with a basic Stripe card payment integration. However, you can replace it with Stripe's Payment Element instead. + +By using the Payment Element, you can offer a unified payment experience that supports various payment methods, including credit cards, PayPal, iDeal, and more, all within a single component. + +## Summary + +By following this tutorial, you'll learn how to: + +- Set up a Medusa application with the Stripe Module Provider. +- Customize the Next.js Starter Storefront to use Stripe's Payment Element. + +*** + +## Step 1: Set Up Medusa Project + +In this step, you'll set up a Medusa application and configure the Stripe Module Provider. You can skip this step if you already have a Medusa application running with the Stripe Module Provider configured. + +### a. Install Medusa Application + +### Prerequisites + +- [Node.js v20+](https://nodejs.org/en/download) +- [Git CLI tool](https://git-scm.com/downloads) +- [PostgreSQL](https://www.postgresql.org/download/) + +To install the Medusa application, run the following command: + +```bash +npx create-medusa-app@latest +``` + +You'll first be asked for the project's name. Then, when you're asked whether you want to install the Next.js Starter Storefront, choose `Y` for yes. + +Afterwards, the installation process will start, which will install the Medusa application in a directory with your project's name, and the Next.js Starter Storefront in a directory with the `{project-name}-storefront` name. + +The Medusa application is composed of a headless Node.js server and an admin dashboard. The storefront is installed or custom-built separately and connects to the Medusa application through its REST endpoints, called [API routes](https://docs.medusajs.com/docs/learn/fundamentals/api-routes/index.html.md). Learn more about Medusa's architecture in [this documentation](https://docs.medusajs.com/docs/learn/introduction/architecture/index.html.md). + +Once the installation finishes successfully, the Medusa Admin dashboard will open with a form to create a new user. Enter the user's credentials and submit the form. + +Afterwards, you can log in with the new user and explore the dashboard. The Next.js Starter Storefront is also running at `http://localhost:8000`. + +Check out the [troubleshooting guides](https://docs.medusajs.com/Users/shahednasser/medusa/www/apps/resources/app/troubleshooting/create-medusa-app-errors/index.html.md) for help. + +### b. Configure Stripe Module Provider + +Next, you'll configure the [Stripe Module Provider](https://docs.medusajs.com/Users/shahednasser/medusa/www/apps/resources/app/commerce-modules/payment/payment-provider/stripe/index.html.md) in your Medusa application. The Stripe Module Provider allows you to accept payments through Stripe in your Medusa application. + +### Prerequisites + +- [Stripe account↗](https://stripe.com) +- [Stripe Secret API Key](https://support.stripe.com/questions/locate-api-keys-in-the-dashboard) + +The Stripe Module Provider is installed by default in your application. To use it, add it to the array of providers passed to the Payment Module in `medusa-config.ts`: + +```ts title="medusa-config.ts" badgeLabel="Medusa Application" badgeColor="green" +module.exports = defineConfig({ + // ... + modules: [ + { + resolve: "@medusajs/medusa/payment", + options: { + providers: [ + { + resolve: "@medusajs/medusa/payment-stripe", + id: "stripe", + options: { + apiKey: process.env.STRIPE_API_KEY, + }, + }, + ], + }, + }, + ], +}) +``` + +For more details about other available options and the webhook URLs that Medusa provides, refer to the [Stripe Module Provider](https://docs.medusajs.com/Users/shahednasser/medusa/www/apps/resources/app/commerce-modules/payment/payment-provider/stripe/index.html.md) documentation. + +### c. Set Environment Variables + +Next, make sure to add the necessary environment variables for the above options in `.env` in your Medusa application: + +```bash badgeLabel="Medusa Application" badgeColor="green" +STRIPE_API_KEY= +``` + +Where `` is your Stripe [Secret API Key](https://support.stripe.com/questions/locate-api-keys-in-the-dashboard). + +You also need to add the Stripe [Publishable API Key](https://support.stripe.com/questions/locate-api-keys-in-the-dashboard) in the Next.js Starter Storefront's environment variables: + +```bash badgeLabel="Storefront" badgeColor="blue" +NEXT_PUBLIC_STRIPE_KEY= +``` + +Where `` is your Stripe [Publishable API Key](https://support.stripe.com/questions/locate-api-keys-in-the-dashboard). + +### d. Enable Stripe in a Region + +Finally, you need to add Stripe as a payment provider to one or more regions in your Medusa Admin. This will allow customers to use Stripe as a payment method during checkout. + +To do that: + +1. Log in to your Medusa Admin dashboard at `http://localhost:9000/app`. +2. Go to Settings -> Regions. +3. Select a region you want to enable Stripe for (or create a new one). +4. Click the icon on the upper right corner. +5. Choose "Edit" from the dropdown menu. +6. In the Payment Providers field, select “Stripe (STRIPE)” +7. Click the Save button. + +Do this for all regions you want to enable Stripe for. + +*** + +## Step 2: Update Payment Step in Checkout + +You'll start customizing the Next.js Starter Storefront by updating the payment step in the checkout process. By default, the payment step is implemented to show all available payment methods in the region the customer is in, and allows the customer to select one of them. + +In this step, you'll replace the current payment method selection with Stripe's Payment Element. You'll no longer need to handle different payment methods separately, as the Payment Element will automatically adapt to the available methods configured in your Stripe account. + +### a. Update the Stripe SDKs + +To ensure you have the latest Stripe packages, update the `@stripe/react-stripe-js` and `@stripe/stripe-js` packages in your Next.js Starter Storefront: + +```bash npm2yarn badgeLabel="Storefront" badgeColor="blue" +npm install @stripe/react-stripe-js@latest @stripe/stripe-js@latest +``` + +### b. Update the Payment Component + +The `Payment` component in `src/modules/checkout/components/payment/index.tsx` shows the payment step in checkout. It handles the payment method selection and submission. You'll update this component first to use the Payment Element. + +The full final code is available at the end of the section. + +First, add the following imports at the top of the file: + +```tsx title="src/modules/checkout/components/payment/index.tsx" +// ...other imports +import { useContext } from "react" +import { PaymentElement, useElements, useStripe } from "@stripe/react-stripe-js" +import { StripePaymentElementChangeEvent } from "@stripe/stripe-js" +import { StripeContext } from "../payment-wrapper/stripe-wrapper" +``` + +You import components from the Stripe SDKs, and the `StripeContext` created in the `StripeWrapper` component. This context will allow you to check whether Stripe is ready to be used. + +Next, in the `Payment` component, replace the existing state variables with the following: + +```tsx title="src/modules/checkout/components/payment/index.tsx" +const [isLoading, setIsLoading] = useState(false) +const [error, setError] = useState(null) +const [stripeComplete, setStripeComplete] = useState(false) +const [selectedPaymentMethod, setSelectedPaymentMethod] = useState("") + +const stripeReady = useContext(StripeContext) +const stripe = stripeReady ? useStripe() : null +const elements = stripeReady ? useElements() : null +``` + +You define the following variables: + +- `isLoading`: A boolean that indicates whether the payment is being processed. +- `error`: A string that holds any error message related to the payment. +- `stripeComplete`: A boolean that indicates whether operations with the Stripe Payment Element are complete. When this is enabled, the customer can proceed to the "Review" checkout step. +- `selectedPaymentMethod`: A string that holds the currently selected payment method in the Payment Element. For example, `card` or `eps`. +- `stripeReady`: A boolean that indicates whether Stripe is ready to be used, fetched from the `StripeContext`. + - This context is defined in `src/modules/checkout/components/payment-wrapper/stripe-wrapper.tsx` and it wraps the checkout page in Stripe's `Elements` component, which initializes the Stripe SDKs. +- `stripe`: The Stripe instance, which is used to interact with the Stripe API. It's only initialized if `stripeReady` from the Stripe context is true. +- `elements`: The Stripe Elements instance, which is used to manage the Payment Element. It's also only initialized if `stripeReady` is true. + +Next, add the following function to the `Payment` component to handle changes in the Payment Element: + +```tsx title="src/modules/checkout/components/payment/index.tsx" +const handlePaymentElementChange = async ( + event: StripePaymentElementChangeEvent +) => { + // Catches the selected payment method and sets it to state + if (event.value.type) { + setSelectedPaymentMethod(event.value.type) + } + + // Sets stripeComplete on form completion + setStripeComplete(event.complete) + + // Clears any errors on successful completion + if (event.complete) { + setError(null) + } +} +``` + +This function will be called on every change in Stripe's Payment Element, such as when the customer selects a payment method. + +In the function, you update the `selectedPaymentMethod` state with the type of payment method selected by the customer, set `stripeComplete` to true when the payment element is complete, and clear any error messages when the payment element is complete. + +Then, to customize the payment step's submission, replace the `handleSubmit` function with the following: + +```tsx title="src/modules/checkout/components/payment/index.tsx" +const handleSubmit = async () => { + setIsLoading(true) + setError(null) + + try { + // Check if the necessary context is ready + if (!stripe || !elements) { + setError("Payment processing not ready. Please try again.") + return + } + + // Submit the payment method details + await elements.submit().catch((err) => { + console.error(err) + setError(err.message || "An error occurred with the payment") + return + }) + + // Navigate to the final checkout step + router.push(pathname + "?" + createQueryString("step", "review"), { + scroll: false, + }) + } catch (err: any) { + setError(err.message) + } finally { + setIsLoading(false) + } +} +``` + +In this function, you use the `elements.submit()` method to submit the payment method details entered by the customer in the Payment Element. This doesn't actually confirm the payment yet; it just prepares the payment method for confirmation. + +Once the payment method is submitted successfully, you navigate the customer to the Review step of the checkout process. + +Next, to make sure a payment session is initialized when the customer reaches the payment step, add the following to the `Payment` component: + +```tsx title="src/modules/checkout/components/payment/index.tsx" +const initStripe = async () => { + try { + await initiatePaymentSession(cart, { + // TODO: change the provider ID if using a different ID in medusa-config.ts + provider_id: "pp_stripe_stripe", + }) + } catch (err) { + console.error("Failed to initialize Stripe session:", err) + setError("Failed to initialize payment. Please try again.") + } +} + +useEffect(() => { + if (!activeSession && isOpen) { + initStripe() + } +}, [cart, isOpen, activeSession]) +``` + +You add an `initStripe` function that initiates a payment session in the Medusa server. Notice that you set the provider ID to `pp_stripe_stripe`, which is the ID of the Stripe payment provider in your Medusa application if the ID in `medusa-config.ts` is `stripe`. + +If you used a different ID, change the `stripe` in the middle accordingly. For example, if you set the ID to `payment`, you would set the `provider_id` to `pp_payment_stripe`. + +You also add a `useEffect` hook that calls `initStripe` when the payment step is opened and there is no active payment session. + +You'll now update the component's return statement to render the Payment Element. + +First, find the following lines in the return statement: + +```tsx title="src/modules/checkout/components/payment/index.tsx" +{!paidByGiftcard && availablePaymentMethods?.length && ( + <> + setPaymentMethod(value)} + > + {/* ... */} + + +)} +``` + +And replace them with the following: + +```tsx title="src/modules/checkout/components/payment/index.tsx" +{!paidByGiftcard && + availablePaymentMethods?.length && + stripeReady && ( +
+ +
+ ) +} +``` + +You replace the radio group with the payment methods available in Medusa with the `PaymentElement` component from the Stripe SDK. You pass it the following props: + +- `onChange`: A callback function that is called when the payment element's state changes. You pass the `handlePaymentElementChange` function you defined earlier. +- `options`: An object that contains options for the payment element, such as the `layout`. Refer to [Stripe's documentation](https://docs.stripe.com/payments/payment-element#options) for other options available. + +Next, find the button rendered afterward and replace it with the following: + +```tsx title="src/modules/checkout/components/payment/index.tsx" + +``` + +You update the button's `disabled` condition and text. + +After that, to ensure the customer can only proceed to the review step if a payment method is selected, find the following lines in the return statement: + +```tsx title="src/modules/checkout/components/payment/index.tsx" +{cart && paymentReady && activeSession ? ( + // rest of code... +)} +``` + +And replace them with the following: + +```tsx title="src/modules/checkout/components/payment/index.tsx" +{cart && paymentReady && activeSession && selectedPaymentMethod ? ( + // rest of code... +)} +``` + +You update the condition to also check if a payment method is selected within the Payment Element. + +Finally, find the following lines in the return statement: + +```tsx title="src/modules/checkout/components/payment/index.tsx" + + {isStripeFunc(selectedPaymentMethod) && cardBrand + ? cardBrand + : "Another step will appear"} + +``` + +And replace them with the following: + +```tsx +Another step may appear +``` + +You show the same text for all payment methods since they're handled by Stripe's Payment Element. + +You've now finished customizing the Payment step to show the Stripe Payment Element. This component will show the payment methods configured in your Stripe account. + +Feel free to remove unused imports, variables, and functions in the file. + +### Full updated code for src/modules/checkout/components/payment/index.tsx + +```tsx title="src/modules/checkout/components/payment/index.tsx" +"use client" + +import { isStripe as isStripeFunc, paymentInfoMap } from "@lib/constants" +import { initiatePaymentSession } from "@lib/data/cart" +import { CheckCircleSolid, CreditCard } from "@medusajs/icons" +import { Button, Container, Heading, Text, clx } from "@medusajs/ui" +import ErrorMessage from "@modules/checkout/components/error-message" +import Divider from "@modules/common/components/divider" +import { usePathname, useRouter, useSearchParams } from "next/navigation" +import { useCallback, useContext, useEffect, useState } from "react" +import { PaymentElement, useElements, useStripe } from "@stripe/react-stripe-js" +import { StripePaymentElementChangeEvent } from "@stripe/stripe-js" +import { StripeContext } from "../payment-wrapper/stripe-wrapper" + +const Payment = ({ + cart, + availablePaymentMethods, +}: { + cart: any + availablePaymentMethods: any[] +}) => { + const activeSession = cart.payment_collection?.payment_sessions?.find( + (paymentSession: any) => paymentSession.status === "pending" + ) + const stripeReady = useContext(StripeContext) + + const [isLoading, setIsLoading] = useState(false) + const [error, setError] = useState(null) + const [stripeComplete, setStripeComplete] = useState(false) + const [selectedPaymentMethod, setSelectedPaymentMethod] = useState("") + + const stripe = stripeReady ? useStripe() : null + const elements = stripeReady ? useElements() : null + + const searchParams = useSearchParams() + const router = useRouter() + const pathname = usePathname() + + const isOpen = searchParams.get("step") === "payment" + + const handlePaymentElementChange = async ( + event: StripePaymentElementChangeEvent + ) => { + // Catches the selected payment method and sets it to state + if (event.value.type) { + setSelectedPaymentMethod(event.value.type) + } + + // Sets stripeComplete on form completion + setStripeComplete(event.complete) + + // Clears any errors on successful completion + if (event.complete) { + setError(null) + } + } + + const setPaymentMethod = async (method: string) => { + setError(null) + setSelectedPaymentMethod(method) + if (isStripeFunc(method)) { + await initiatePaymentSession(cart, { + provider_id: method, + }) + } + } + + const paidByGiftcard = + cart?.gift_cards && cart?.gift_cards?.length > 0 && cart?.total === 0 + + const paymentReady = + (activeSession && cart?.shipping_methods.length !== 0) || paidByGiftcard + + const createQueryString = useCallback( + (name: string, value: string) => { + const params = new URLSearchParams(searchParams) + params.set(name, value) + + return params.toString() + }, + [searchParams] + ) + + const handleEdit = () => { + router.push(pathname + "?" + createQueryString("step", "payment"), { + scroll: false, + }) + } + + const handleSubmit = async () => { + setIsLoading(true) + setError(null) + + try { + // Check if the necessary context is ready + if (!stripe || !elements) { + setError("Payment processing not ready. Please try again.") + return + } + + // Submit the payment method details + await elements.submit().catch((err) => { + console.error(err) + setError(err.message || "An error occurred with the payment") + return + }) + + // Navigate to the final checkout step + router.push(pathname + "?" + createQueryString("step", "review"), { + scroll: false, + }) + } catch (err: any) { + setError(err.message) + } finally { + setIsLoading(false) + } + } + + const initStripe = async () => { + try { + await initiatePaymentSession(cart, { + provider_id: "pp_stripe_stripe", + }) + } catch (err) { + console.error("Failed to initialize Stripe session:", err) + setError("Failed to initialize payment. Please try again.") + } + } + + useEffect(() => { + if (!activeSession && isOpen) { + initStripe() + } + }, [cart, isOpen, activeSession]) + + useEffect(() => { + setError(null) + }, [isOpen]) + + return ( +
+
+ + Payment + {!isOpen && paymentReady && } + + {!isOpen && paymentReady && ( + + + + )} +
+
+
+ {!paidByGiftcard && + availablePaymentMethods?.length && + stripeReady && ( +
+ +
+ ) + } + + {paidByGiftcard && ( +
+ + Payment method + + + Gift card + +
+ )} + + + + +
+ +
+ {cart && paymentReady && activeSession && selectedPaymentMethod ? ( +
+
+ + Payment method + + + {paymentInfoMap[activeSession?.provider_id]?.title || + activeSession?.provider_id} + +
+
+ + Payment details + +
+ + {paymentInfoMap[selectedPaymentMethod]?.icon || ( + + )} + + Another step may appear +
+
+
+ ) : paidByGiftcard ? ( +
+ + Payment method + + + Gift card + +
+ ) : null} +
+
+ +
+ ) +} + +export default Payment +``` + +### c. Add Icons and Titles for Payment Methods + +After a customer enters their payment details and proceeds to the Review step, the payment method is displayed in the collapsed Payment step. + +To ensure the correct icon and title are shown for your payment methods configured through Stripe, you can add them in the `paymentInfoMap` object defined in `src/lib/constants.tsx`. + +For example: + +```tsx title="src/lib/constants.tsx" +export const paymentInfoMap: Record< + string, + { title: string; icon: React.JSX.Element } +> = { + // ... + card: { + title: "Credit card", + icon: , + }, + paypal: { + title: "PayPal", + icon: , + }, +} +``` + +For every payment method you want to customize its display, add an entry in the `paymentInfoMap` object. The key should match the [type enum in Stripe's Payment Element](https://docs.stripe.com/api/payment_methods/object#payment_method_object-type), and the value is an object with the following properties: + +- `title`: The title to display for the payment method. +- `icon`: A JSX element representing the icon for the payment method. You can use icons from [Medusa UI](https://docs.medusajs.com/ui/icons/overview/index.html.md) or custom icons. + +### Test it out + +Before you test out the payment checkout step, make sure you have the necessary [payment methods configured in Stripe](https://docs.stripe.com/payments/payment-methods/overview). + +Then, follow these steps to test the payment checkout step in your Next.js Starter Storefront: + +1. Start the Medusa application with the following command: + +```bash npm2yarn badgeLabel="Medusa Application" badgeColor="green" +npm run dev +``` + +2. Start the Next.js Starter Storefront with the following command: + +```bash npm2yarn badgeLabel="Storefront" badgeColor="blue" +npm run dev +``` + +3. Open the storefront at `http://localhost:8000` in your browser. +4. Go to Menu -> Store, choose a product, and add it to the cart. +5. Proceed to checkout by clicking the cart icon in the top right corner, then click "Go to checkout". +6. Complete the Address and Delivery steps that are before the Payment step. +7. Once you reach the Payment step, your Stripe Payment Element should appear and list the different payment methods you’ve enabled in your Stripe account. + +At this point, you can proceed to the Review step, but you can't confirm the payment with Stripe and place an order in Medusa. You'll customize the payment button in the next step to handle the payment confirmation with Stripe. + +![The Stripe Payment Element allows you to pay with different payment methods like PayPal](https://res.cloudinary.com/dza7lstvk/image/upload/v1734005395/Medusa%20Resources/Screenshot_2024-12-10_at_18.04.52_wemqwg.jpg) + +*** + +## Step 3: Update the Payment Button + +Next, you'll update the payment button in the Review step of the checkout process. This button is used to confirm the payment with Stripe, then place the order in Medusa. + +In this step, you'll customize the `PaymentButton` component in `src/modules/checkout/components/payment-button/index.tsx` to support confirming the payment with Stripe's Payment Element. + +The full final code is available at the end of the section. + +Start by adding the following imports at the top of the file: + +```tsx title="src/modules/checkout/components/payment-button/index.tsx" +// ...other imports +import { useEffect } from "react" +import { useParams, usePathname, useRouter } from "next/navigation" +``` + +Then, in the `StripePaymentButton` component, add the following variables: + +```tsx title="src/modules/checkout/components/payment-button/index.tsx" +const { countryCode } = useParams() +const router = useRouter() +const pathname = usePathname() +const paymentSession = cart.payment_collection?.payment_sessions?.find( + // TODO change the provider_id if using a different ID in medusa-config.ts + (session) => session.provider_id === "pp_stripe_stripe" +) +``` + +You define the following variables: + +- `countryCode`: The country code of the customer's region, fetched from the URL. You'll use this later to create Stripe's redirect URL. +- `router`: The Next.js router instance. You'll use this to redirect back to the payment step if necessary. +- `pathname`: The current pathname. You'll use this when redirecting back to the payment step if necessary. +- `paymentSession`: The Medusa payment session for the Stripe payment provider. This is used to get the `clientSecret` needed to confirm the payment. + - Notice that the provider ID is set to `pp_stripe_stripe`, which is the ID of the Stripe payment provider in your Medusa application if the ID in `medusa-config.ts` is `stripe`. If you used a different ID, change the `stripe` in the middle accordingly. + +After that, change the `handlePayment` function to the following: + +```tsx title="src/modules/checkout/components/payment-button/index.tsx" +const handlePayment = async () => { + if (!stripe || !elements || !cart) { + return + } + setSubmitting(true) + + const { error: submitError } = await elements.submit() + if (submitError) { + setErrorMessage(submitError.message || null) + setSubmitting(false) + return + } + + const clientSecret = paymentSession?.data?.client_secret as string + + await stripe + .confirmPayment({ + elements, + clientSecret, + confirmParams: { + return_url: `${ + window.location.origin + }/api/capture-payment/${cart.id}?country_code=${countryCode}`, + payment_method_data: { + billing_details: { + name: + cart.billing_address?.first_name + + " " + + cart.billing_address?.last_name, + address: { + city: cart.billing_address?.city ?? undefined, + country: cart.billing_address?.country_code ?? undefined, + line1: cart.billing_address?.address_1 ?? undefined, + line2: cart.billing_address?.address_2 ?? undefined, + postal_code: cart.billing_address?.postal_code ?? undefined, + state: cart.billing_address?.province ?? undefined, + }, + email: cart.email, + phone: cart.billing_address?.phone ?? undefined, + }, + }, + }, + redirect: "if_required", + }) + .then(({ error, paymentIntent }) => { + if (error) { + const pi = error.payment_intent + + if ( + (pi && pi.status === "requires_capture") || + (pi && pi.status === "succeeded") + ) { + onPaymentCompleted() + return + } + + setErrorMessage(error.message || null) + setSubmitting(false) + return + } + + if ( + paymentIntent.status === "requires_capture" || + paymentIntent.status === "succeeded" + ) { + onPaymentCompleted() + } + }) +} +``` + +In the function, you: + +- Ensure that the `stripe`, `elements`, and `cart` are available before proceeding. +- Set the `submitting` state to true to indicate that the payment is being processed. +- Use `elements.submit()` to submit the payment method details that the customer enters in the Payment Element. This ensures all necessary payment details are entered. +- Use `stripe.confirmPayment()` to confirm the payment with Stripe. You pass it the following details: + - `elements`: The Stripe Elements instance that contains the Payment Element. + - `clientSecret`: The client secret from the payment session, which is used to confirm the payment. + - `confirmParams`: An object that contains the parameters for confirming the payment. + - `return_url`: The URL to redirect the customer to after the payment is confirmed. This redirect URL is useful when using providers like PayPal, where the customer is redirected to complete the payment externally. You'll create the route in your Next.js Starter Storefront in the next step. + - `payment_method_data`: An object that contains the billing details of the customer. + - `redirect`: The redirect behavior for the payment confirmation. By setting `redirect: "if_required"`, you'll only redirect to the `return_url` if the payment is completed externally. +- Handle the response from `confirmPayment()`. + - If the payment is successful or requires capture, you call the `onPaymentCompleted` function to complete the payment and place the order. + - If an error occurred, you set the `errorMessage` state variable to show the error to the customer. + +Finally, add the following `useEffect` hooks to the `StripePaymentButton` component: + +```tsx title="src/modules/checkout/components/payment-button/index.tsx" +useEffect(() => { + if (cart.payment_collection?.status === "authorized") { + onPaymentCompleted() + } +}, [cart.payment_collection?.status]) + +useEffect(() => { + elements?.getElement("payment")?.on("change", (e) => { + if (!e.complete) { + // redirect to payment step if not complete + router.push(pathname + "?step=payment", { + scroll: false, + }) + } + }) +}, [elements]) +``` + +You add two effects: + +- An effect that runs when the status of the cart's payment collection changes. It triggers the `onPaymentCompleted` function to finalize the order placement when the payment collection status is `authorized`. +- An effect that listens for changes in the Payment Element. If the payment element is not complete, it redirects the customer back to the payment step. This is useful if the customer refreshes the page or navigates away, leading to incomplete payment details. + +You've finalized changes to the `PaymentButton` component. Feel free to remove unused imports, variables, and functions in the file. + +You still need to add the redirect URL route before you can test the payment button. You'll do that in the next step. + +### Full updated code for src/modules/checkout/components/payment-button/index.tsx + +```tsx title="src/modules/checkout/components/payment-button/index.tsx" +"use client" + +import { isManual, isStripe } from "@lib/constants" +import { placeOrder } from "@lib/data/cart" +import { HttpTypes } from "@medusajs/types" +import { Button } from "@medusajs/ui" +import { useElements, useStripe } from "@stripe/react-stripe-js" +import React, { useState, useEffect } from "react" +import ErrorMessage from "../error-message" +import { useParams, usePathname, useRouter } from "next/navigation" + + +type PaymentButtonProps = { + cart: HttpTypes.StoreCart + "data-testid": string +} + +const PaymentButton: React.FC = ({ + cart, + "data-testid": dataTestId, +}) => { + const notReady = + !cart || + !cart.shipping_address || + !cart.billing_address || + !cart.email || + (cart.shipping_methods?.length ?? 0) < 1 + + const paymentSession = cart.payment_collection?.payment_sessions?.[0] + + switch (true) { + case isStripe(paymentSession?.provider_id): + return ( + + ) + case isManual(paymentSession?.provider_id): + return ( + + ) + default: + return + } +} + +const StripePaymentButton = ({ + cart, + notReady, + "data-testid": dataTestId, +}: { + cart: HttpTypes.StoreCart + notReady: boolean + "data-testid"?: string +}) => { + const [submitting, setSubmitting] = useState(false) + const [errorMessage, setErrorMessage] = useState(null) + + const { countryCode } = useParams() + const router = useRouter() + const pathname = usePathname() + const paymentSession = cart.payment_collection?.payment_sessions?.find( + (session) => session.provider_id === "pp_stripe_stripe" + ) + + const onPaymentCompleted = async () => { + await placeOrder() + .catch((err) => { + setErrorMessage(err.message) + }) + .finally(() => { + setSubmitting(false) + }) + } + + const stripe = useStripe() + const elements = useElements() + + const disabled = !stripe || !elements ? true : false + + const handlePayment = async () => { + if (!stripe || !elements || !cart) { + return + } + + setSubmitting(true) + + + const { error: submitError } = await elements.submit() + if (submitError) { + setErrorMessage(submitError.message || null) + setSubmitting(false) + return + } + + const clientSecret = paymentSession?.data?.client_secret as string + + await stripe + .confirmPayment({ + elements, + clientSecret, + confirmParams: { + return_url: `${window.location.origin}/api/capture-payment/${cart.id}?country_code=${countryCode}`, + payment_method_data: { + billing_details: { + name: + cart.billing_address?.first_name + + " " + + cart.billing_address?.last_name, + address: { + city: cart.billing_address?.city ?? undefined, + country: cart.billing_address?.country_code ?? undefined, + line1: cart.billing_address?.address_1 ?? undefined, + line2: cart.billing_address?.address_2 ?? undefined, + postal_code: cart.billing_address?.postal_code ?? undefined, + state: cart.billing_address?.province ?? undefined, + }, + email: cart.email, + phone: cart.billing_address?.phone ?? undefined, + }, + }, + }, + redirect: "if_required", + }) + .then(({ error, paymentIntent }) => { + if (error) { + const pi = error.payment_intent + + if ( + (pi && pi.status === "requires_capture") || + (pi && pi.status === "succeeded") + ) { + onPaymentCompleted() + return + } + + setErrorMessage(error.message || null) + setSubmitting(false) + return + } + + if ( + paymentIntent.status === "requires_capture" || + paymentIntent.status === "succeeded" + ) { + onPaymentCompleted() + } + }) + } + + useEffect(() => { + if (cart.payment_collection?.status === "authorized") { + onPaymentCompleted() + } + }, [cart.payment_collection?.status]) + + useEffect(() => { + elements?.getElement("payment")?.on("change", (e) => { + if (!e.complete) { + // redirect to payment step if not complete + router.push(pathname + "?step=payment", { + scroll: false, + }) + } + }) + }, [elements]) + + return ( + <> + + + + ) +} + +const ManualTestPaymentButton = ({ notReady }: { notReady: boolean }) => { + const [submitting, setSubmitting] = useState(false) + const [errorMessage, setErrorMessage] = useState(null) + + const onPaymentCompleted = async () => { + await placeOrder() + .catch((err) => { + setErrorMessage(err.message) + }) + .finally(() => { + setSubmitting(false) + }) + } + + const handlePayment = () => { + setSubmitting(true) + + onPaymentCompleted() + } + + return ( + <> + + + + ) +} + +export default PaymentButton +``` + +*** + +## Step 4: Handle External Payment Callbacks + +Some payment providers, such as PayPal, require the customers to perform actions on their portal before authorizing or confirming the payment. In those scenarios, you need an endpoint that the provider redirects the customer to complete their purchase. + +In this step, you'll create an API route in your Next.js Starter Storefront that handles this use case. This route is the `return_url` route you passed to the `stripe.confirmPayment` earlier. + +Create the file `src/app/api/capture-payment/[cartId]/route.ts` with the following content: + +```tsx title="src/app/api/capture-payment/[cartId]/route.ts" +import { placeOrder, retrieveCart } from "@lib/data/cart" +import { NextRequest, NextResponse } from "next/server" + +type Params = Promise<{ cartId: string }> + +export async function GET(req: NextRequest, { params }: { params: Params }) { + const { cartId } = await params + const { origin, searchParams } = req.nextUrl + + const paymentIntent = searchParams.get("payment_intent") + const paymentIntentClientSecret = searchParams.get( + "payment_intent_client_secret" + ) + const redirectStatus = searchParams.get("redirect_status") || "" + const countryCode = searchParams.get("country_code") + + const cart = await retrieveCart(cartId) + + if (!cart) { + return NextResponse.redirect(`${origin}/${countryCode}`) + } + + const paymentSession = cart.payment_collection?.payment_sessions?.find( + (payment) => payment.data.id === paymentIntent + ) + + if ( + !paymentSession || + paymentSession.data.client_secret !== paymentIntentClientSecret || + !["pending", "succeeded"].includes(redirectStatus) || + !["pending", "authorized"].includes(paymentSession.status) + ) { + return NextResponse.redirect( + `${origin}/${countryCode}/cart?step=review&error=payment_failed` + ) + } + + const order = await placeOrder(cartId) + + return NextResponse.redirect( + `${origin}/${countryCode}/order/${order.id}/confirmed` + ) +} +``` + +In this route, you validate that the payment intent and client secret match the cart's payment session data. If so, you place the order and redirect the customer to the order confirmation page. + +If the payment failed or other validation checks fail, you redirect the customer back to the cart page with an error message. + +Stripe will redirect the customer back to this route when using payment methods like PayPal. + +*** + +### Test Payment Confirmation and Order placement + +You can now test out the entire checkout flow with payment confirmation and order placement. + +Start the Medusa application and the Next.js Starter Storefront as you did in the previous steps, and proceed to checkout. + +In the payment step, select a payment method, such as card or PayPal. Fill out the necessary details in the Payment Element, then click the "Continue to review" button. + +Find test cards in [Stripe's documentation](https://docs.stripe.com/testing#use-test-cards). + +After that, click the "Place order" button. If you selected a payment method that requires external confirmation, such as PayPal, you will be redirected to the PayPal payment page. + +![Example of handling payment through PayPal](https://res.cloudinary.com/dza7lstvk/image/upload/v1734006541/Medusa%20Resources/Screenshot_2024-12-10_at_19.27.13_q0w6u1.jpg) + +Once the payment is confirmed and the order is placed, the customer will be redirected to the order confirmation page, where they can see the order details. + +### Test 3D Secure Payments + +Stripe's Payment Element also supports 3D Secure payments. You can use one of [Stripe's test 3D Secure cards](https://docs.stripe.com/testing?testing-method=card-numbers#regulatory-cards) to test this feature. + +When a customer uses a 3D Secure card, a pop-up will open prompting them to complete the authentication process. If successful, the order will be placed, and the customer will be redirected to the order confirmation page. + +![Stripe 3D Secure pop-up](https://res.cloudinary.com/dza7lstvk/image/upload/v1752577384/Medusa%20Resources/CleanShot_2025-07-15_at_11.12.44_2x_ibjxvx.png) + +### Server Webhook Verification + +Webhook verification is useful to ensure that payment events are handled despite connection issues. The Stripe Module Provider in Medusa provides webhook verification out of the box, so you don't need to implement it yourself. + +Learn more about the webhook API routes and how to configure them in the [Stripe Module Provider](https://docs.medusajs.com/Users/shahednasser/medusa/www/apps/resources/app/commerce-modules/payment/payment-provider/stripe/index.html.md) guide. + +### Testing Declined Payments + +You can use [Stripe's declined test cards](https://docs.stripe.com/testing#declined-payments) to test declined payments. When a payment is declined, the customer will be redirected back to the payment step to fix their payment details as necessary. + + +# Revalidate Cache in Next.js Starter Storefront + +In this guide, you'll learn about the general approach to revalidating cache in the Next.js Starter Storefront when data is updated in the Medusa application. + +## Approach Overview + +By default, the data that the Next.js Starter Storefront retrieves from the Medusa application is cached in the browser. This cache is used to improve the performance and speed of the storefront. + +In some cases, you may need to revalidate the cache in the storefront when data is updated in the Medusa application. For example, when a product variant's price is updated in the Medusa application, you may want to revalidate the cache in the storefront to reflect the updated price. + +You're free to choose the approach that works for your use case, custom requirements, and tech stack. The approach that Medusa recommends is: + +1. Create a [subscriber](https://docs.medusajs.com/docs/learn/fundamentals/events-and-subscribers/index.html.md) in the Medusa application that listens for the event that triggers the data update. For example, you can listen to the `product.updated` event. +2. In the subscriber, send a request to a custom endpoint in the Next.js Starter Storefront to trigger the cache revalidation. +3. Create the custom endpoint in the Next.js Starter Storefront that listens for the request from the subscriber and revalidates the cache. + +Refer to the [Events Reference](https://docs.medusajs.com/references/events/index.html.md) for a full list of events that the Medusa application emits. + +*** + +## Example: Revalidating Cache for Product Update + +Consider you want to revalidate the cache in the Next.js Starter Storefront whenever a product is updated. + +Start by creating the following subscriber in the Medusa application: + +```ts +import type { + SubscriberArgs, + SubscriberConfig, +} from "@medusajs/framework" + +export default async function productUpdatedHandler({ + event: { data }, + container, +}: SubscriberArgs<{ id: string }>) { + // send request to next.js storefront to revalidate cache + await fetch(`${process.env.STOREFRONT_URL}/api/revalidate?tags=products`) +} + +export const config: SubscriberConfig = { + event: "product.updated", +} +``` + +In the subscriber, you send a request to the custom endpoint `/api/revalidate` in the Next.js Starter Storefront. The request includes the query parameter `tags=product-${data.id}` to specify the cache that needs to be revalidated. + +Make sure to set the `STOREFRONT_URL` environment variable in the Medusa application to the URL of the Next.js Starter Storefront. + +Then, create in the Next.js Starter Storefront the custom endpoint that listens for the request and revalidates the cache: + +```ts title="src/app/api/revalidate/route.ts" +import { NextRequest, NextResponse } from "next/server" +import { revalidatePath } from "next/cache" + +export async function GET(req: NextRequest) { + const searchParams = req.nextUrl.searchParams + const tags = searchParams.get("tags") as string + + if (!tags) { + return NextResponse.json({ error: "No tags provided" }, { status: 400 }) + } + + const tagsArray = tags.split(",") + await Promise.all( + tagsArray.map(async (tag) => { + switch (tag) { + case "products": + revalidatePath("/[countryCode]/(main)/store", "page") + revalidatePath("/[countryCode]/(main)/products/[handle]", "page") + // TODO add for other tags + } + }) + ) + + return NextResponse.json({ message: "Revalidated" }, { status: 200 }) +} +``` + +In this example, you create a custom endpoint `/api/revalidate` that revalidates the cache for paths based on the tags passed as query parameters. + +You only handle the case of the `products` tag in this example, but you can extend the switch statement to handle other tags as needed. + +To revalidate the cache, you use Next.js's `revalidatePath` function. Learn more about in the [Next.js documentation](https://nextjs.org/docs/app/api-reference/functions/revalidatePath). + +### Test it Out + +To test out this mechanism, run the Medusa application and the Next.js Starter Storefront. + +Then, update a product in the Medusa application. You can see in the Next.js Starter Storefront's terminal that a request was sent to the `/api/revalidate` endpoint, meaning that the cache was revalidated successfully. + +

Just Getting Started?

diff --git a/www/apps/book/scripts/prepare.mjs b/www/apps/book/scripts/prepare.mjs index b6749283c5..1834d37f50 100644 --- a/www/apps/book/scripts/prepare.mjs +++ b/www/apps/book/scripts/prepare.mjs @@ -219,6 +219,16 @@ async function main() { "service-factory-reference" ), }, + { + dir: path.join( + process.cwd(), + "..", + "resources", + "app", + "nextjs-starter", + "guides" + ), + }, { dir: path.join(process.cwd(), "..", "api-reference", "markdown"), },