Integrating Stripe Payment Elements with Next.js 14 App Router & Webhooks & Typescript

July 24, 2024Integrating Stripe Payment Elements with Next.js 14 App Router & Webhooks & Typescript

Introduction:

When I wanted to implement Stripe into one of my projects, I found that the official documentation was tailored for the Pages Router. There are many tutorials online about integrating Stripe with Next.js App Router, but most of them either make the entire app client-side or fail to show how to properly handle redirection to a functional success page

In this tutorial my main purpose is handling that.

My examples might have Postgres codes because I handle products and order database myself. I did not delete them on purpose but you can always implement your own solution.

First Thing To Do:

You must create a Stripe account and put your keys in an env file.

NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=""
STRIPE_SECRET_KEY=""
STRIPE_SECRET_WEBHOOK_KEY=""

Create a Payment Endpoint

First, I create a route.ts file in my API folder. In this file, I handle the cart items and calculate the total price using my product database. This calculation must be done server-side to prevent users from manipulating the amount on the client side. Additionally, I ensure that the user is authenticated on the server-side.

//src/app/api/payment/route.ts

import { db } from '@/src/database';
import { CartItem } from '@/src/types';
import { authenticatedUser } from '@/src/utils/amplify-server-utils';
import { NextRequest, NextResponse } from 'next/server';
import Stripe from 'stripe';

const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!, {
  typescript: true,
});

const calculateOrderAmount = async (items: CartItem[]) => {
  const productIds = items.map((item) => item.id);
  const prices = await db
    .selectFrom('product_variants')
    .select(['price', 'id'])
    .where('product_variants.id', 'in', productIds)
    .execute();

  const totalPrice = items.reduce((total, item) => {
    const product = prices.find((price) => price.id === item.id);
    return total + (product ? product.price * item.quantity : 0);
  }, 0);

  return Math.round(totalPrice * 100);
};

export async function POST(request: NextRequest) {
  try {
    const {
      items,
      address_id,
    }: { items: CartItem[] | null; address_id: number } = await request.json();
    const response = NextResponse.next();
    const user = await authenticatedUser({ request, response });

    if (!user) {
      return Response.json({ error: 'Forbidden' }, { status: 403 });
    }

    if (!items) {
      return Response.json({ error: 'Invalid Items' }, { status: 400 });
    }

    const total = await calculateOrderAmount(items);

    const paymentIntent = await stripe.paymentIntents.create({
      amount: total,
      currency: 'usd',
      automatic_payment_methods: {
        enabled: true,
      },
      metadata: {
        userId: user.userId,
        items: JSON.stringify(items),
        address_id,
      },
    });

    return Response.json({
      clientSecret: paymentIntent.client_secret,
    });
  } catch (error) {
    console.log(error);
    return Response.json({ error: 'Internal server error' }, { status: 500 });
  }
}

Creating Checkout Form

First, I create a checkout form that utilizes the PaymentElement from Stripe. This form allows users to enter their card details (or choose other payment options) and complete their payment. The return_url is important here; it will redirect the user to that page upon successful payment. We will create this page later.

//src/components/CheckoutForm.tsx

'use client';

import { useState } from 'react';
import styles from './checkoutForm.module.scss';
import {
  PaymentElement,
  useElements,
  useStripe,
} from '@stripe/react-stripe-js';
import { StripePaymentElementOptions } from '@stripe/stripe-js';

export default function CheckoutForm() {
  const stripe = useStripe();
  const elements = useElements();
  const [message, setMessage] = useState<string | null>(null);
  const [isLoading, setIsLoading] = useState(false);

  const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
    e.preventDefault();

    if (!stripe || !elements) {
      return;
    }

    setIsLoading(true);

    const { error } = await stripe.confirmPayment({
      elements,
      confirmParams: {
        return_url: 'http://localhost:3000/checkout/payment-status',
      },
    });

    if (error) {
      if (error.type === 'card_error' || error.type === 'validation_error') {
        setMessage(error.message || 'An error occurred.');
      } else {
        setMessage('An unexpected error occurred.');
      }
    }

    setIsLoading(false);
  };

  const paymentElementOptions: StripePaymentElementOptions = {
    layout: 'tabs',
  };

  return (
    <form
      id="payment-form"
      className={styles.paymentForm}
      onSubmit={handleSubmit}
    >
      <PaymentElement id="payment-element" options={paymentElementOptions} />
      <button
        className={styles.submitButton}
        disabled={isLoading || !stripe || !elements}
        id="submit"
      >
        <span id="button-text">
          {isLoading ? (
            <div className={styles.spinner} id="spinner"></div>
          ) : (
            'Pay now'
          )}
        </span>
      </button>
      {message && (
        <div id="payment-message" className={styles.paymentMessage}>
          {message}
        </div>
      )}
    </form>
  );
}

Creating Checkout Page

At this stage, we need to create three files. I have created a checkout folder and added a layout.tsx file inside it. I use a layout because we must include the Stripe provider within it. This way, our payment-status page, which users are redirected to from the checkout form, will also be within the same provider. This ensures that the that page can listen to Stripe events and display the right process messages.

In the layout.tsx file, I also handle redirecting users to the homepage if there is no clientSecret. This helps ensure that the checkout page is not shown when there are no items ready to buy in the checkout section.

//src/app/checkout/layout.tsx

'use client';

import { loadStripe } from '@stripe/stripe-js';
import { Elements } from '@stripe/react-stripe-js';
import { useEffect, useState } from 'react';
import { useSelector } from '@/src/redux/store';
import { selectAddress, setAddresses } from '@/src/redux/slices/addressSlice';
import { useRouter } from 'next/navigation';

loadStripe(process.env.NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY!)

export default function CheckoutLayout({
  children,
}: {
  children: React.ReactNode;
}) {
  const [clientSecret, setClientSecret] = useState('');
  const { items } = useSelector((state) => state.order);
  const router = useRouter();

  useEffect(() => {
      fetch('/api/payment', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ items }),
      })
        .then((res) => res.json())
        .then((data) => {
          if (data.clientSecret) {
            setClientSecret(data.clientSecret);
          } else {
            router.push('/');
          }
        });
    
  }, []);

  const options = {
    clientSecret,
  };

  return (
    <main>
      {clientSecret && (
        <Elements key={clientSecret} options={options} stripe={stripePromise}>
          {children}
        </Elements>
      )}
    </main>
  );
}

At the same level as layout.tsx, I create a page.tsx file. This file will use the CheckoutForm component.

//src/checkout/page.tsx

'use client';

import CheckoutForm from '@/src/components/CheckoutForm/CheckoutForm';
import styles from './checkout.module.scss';

export default function CheckoutPage() {
  return (
    <main className={styles.main} color="white">
      <h1>Checkout</h1>
         <div className={styles.checkoutForm}>
            <CheckoutForm />
         </div>   
    </main>
  );
}

Creating Payment Status Page

We need to create a page that the checkoutForm will redirect to, as specified in the return_url. Some might think this page is only for showing a success message, but it actually serves several important purposes.

You can handle validation errors in the checkoutForm, but other payment issues need to be addressed on this return_url page. Stripe adds extra parameters to the URL when redirecting users, so we use retrievePaymentIntent on this page to check the payment status and display the appropriate messages to the user.

This page can also be used to redirect users back to the checkout page, clear the cart if the payment was successful, or handle other tasks based on the payment outcome.

//src/app/checkout/payment-status/page.tsx

'use client';

import { useState, useEffect } from 'react';
import { useStripe } from '@stripe/react-stripe-js';
import styles from './complete.module.scss';
import { useDispatch } from '@/src/redux/store';
import { clearCart } from '@/src/redux/slices/cartSlice';
import Container from '@/src/components/Container/Container';
import { useRouter } from 'next/navigation';
import Link from 'next/link';
import { clearOrder } from '@/src/redux/slices/orderSlice';

export default function PaymentStatusPage() {
  const [message, setMessage] = useState({ message: '', success: false });
  const stripe = useStripe();
  const dispatch = useDispatch();
  const router = useRouter();

  useEffect(() => {
    if (!stripe) {
      return;
    }

    const clientSecret = new URLSearchParams(window.location.search).get(
      'payment_intent_client_secret'
    );

    if (!clientSecret) {
      router.push('/');
      return;
    }

    stripe.retrievePaymentIntent(clientSecret).then(({ paymentIntent }) => {
      if (paymentIntent) {
        switch (paymentIntent.status) {
          case 'succeeded':
            dispatch(clearOrder());
            dispatch(clearCart());
            setMessage({
              message:
                'Thank you for your purchase! Your payment was successful',
              success: true,
            });
            break;
          case 'processing':
            setMessage({
              message: 'Your payment is currently being processed',
              success: false,
            });
            break;
          case 'requires_payment_method':
            setMessage({
              message: 'Unfortunately, your payment could not be completed',
              success: false,
            });
            router.push('/checkout');
            break;
          default:
            setMessage({
              message:
                'An unexpected error occurred. Please contact our support team for assistance.',
              success: false,
            });
            break;
        }
      }
    });
  }, [stripe]);

  return (
      <div className={styles.container}>
        <h1>{message.message}</h1>
        {message.success && (
          <div className={styles.orderLinkContainer}>
            <h2>You can check your order and shipping status from here:</h2>
            <Link href="/account">Go to My Account</Link>
          </div>
        )}
      </div>
  );
}

Creating Webhook

Now we need to create a webhook. Webhooks are very important for handling Stripe events in real-time. They manage tasks such as creating new orders, sending confirmation emails to your customers, and more. In this tutorial, I’ll use a webhook to create orders in my database and save purchased items.

Note: The webhook retrieves metadata from the payment/route.ts file we created earlier.

//src/app/api/webhook/route.ts

import Stripe from 'stripe';
import { NextRequest } from 'next/server';
import { headers } from 'next/headers';
import { db } from '@/src/database';
import { CartItem } from '@/src/types';

const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!, {
  typescript: true,
});

export async function POST(request: NextRequest) {
  const body = await request.text();
  const endpointSecret = process.env.STRIPE_SECRET_WEBHOOK_KEY!;
  const sig = headers().get('stripe-signature') as string;
  let event: Stripe.Event;

  try {
    event = stripe.webhooks.constructEvent(body, sig, endpointSecret);
  } catch (err: any) {
    console.error(`Webhook Error: ${err.message}`);
    return Response.json(`Webhook Error: ${err.message}`, { status: 400 });
  }

  switch (event.type) {
    case 'payment_intent.succeeded':
      const paymentIntent = event.data.object as Stripe.PaymentIntent;

      if (
        !paymentIntent.metadata ||
        !paymentIntent.metadata.userId ||
        !paymentIntent.metadata.items ||
        !paymentIntent.metadata.address_id
      ) {
        console.error('Missing metadata or userId/items/address_id');
        return Response.json('Bad Request: Missing metadata', { status: 400 });
      }

      const userId = paymentIntent.metadata.userId;
      const items: CartItem[] = JSON.parse(paymentIntent.metadata.items);
      const addressId: number = parseInt(paymentIntent.metadata.address_id, 10);

      try {
        await db.transaction().execute(async (trx) => {
          const order = await trx
            .insertInto('orders')
            .values({
              user_sub: userId,
              total_price: paymentIntent.amount_received
                ? paymentIntent.amount_received / 100
                : 0,
              order_date: new Date(),
              address_id: addressId,
            })
            .returning('id')
            .executeTakeFirstOrThrow();

          await Promise.all(
            items.map(async (item) => {
              return await trx
                .insertInto('order_items')
                .values({
                  order_id: order.id,
                  product_variant_id: item.id,
                  quantity: item.quantity,
                })
                .returningAll()
                .executeTakeFirst();
            })
          );
        });

        console.log('Order and order items successfully inserted.');
      } catch (error: any) {
        console.error(`Database Error: ${error.message}`);
        return Response.json(`Database Error: ${error.message}`, {
          status: 500,
        });
      }
      break;

    default:
      console.log(`Unhandled event type ${event.type}`);
  }

  return Response.json('Event received', { status: 200 });
}

Testing Webhook

If you want to test your webhook locally you need to download Stripe CLI and listen your webhook route.

Stripe CLI download link: https://docs.stripe.com/stripe-cli

After installing Strip CLI, write stripe loginin your terminal. Then paste this:

"stripe:listen": "stripe listen --forward-to http://localhost:3000/api/webhook"

You must keep this terminal open while testing your app.

For production you must visit https://dashboard.stripe.com/webhooks and create a new endpoint for your webapp.

All Done!

I hope this tutorial was helpful in setting up Stripe with your Next.js application. If you have any questions or need further assistance, please don't hesitate to contact me. Additionally, you can leave comments or feedback on the Medium article. Happy coding