moon

I'm a full-stack developer from Istanbul, building web and mobile apps with Next.js, React Native, and Node.js. I enjoy taking products from idea to production and care about clean code and architecture more than 'it just works'.

Bulut Yerli

projects

watchlabb

Watchlabb Web & Mobile

A watch marketplace where users can list for sale or create real-time auctions for watches. Built solo — web and mobile. Currently serving 4,000+ registered users and generating revenue.

  • Real-time auctions with live bidding and auto-end handling
  • KYC + SMS verification for users and listings
  • Credit card payments & native in-app purchases for mobile
  • Custom-built billing pipeline with cron jobs, processing queues & automated invoicing
  • Auto-generated sale certificates & auto Instagram share cards
  • Push, in-app & email notifications with per-user preferences
  • Role-based access control for admins & moderators
  • typescript
  • next.js
  • react native
  • expo
  • postgresql
  • supabase
  • tailwind
  • unistyles
workwise screenshot

workwise

A company management app where HR, managers, and employees each have their own access level and features.

HR manages staff and salaries, managers approve leave requests, employees can submit requests and view the team.

Comes with a financial dashboard, org chart, and a documented REST API.

  • typescript
  • react
  • node.js
  • express
  • postgresql
  • docker
  • jest
nuvola coffee shop screenshot

nuvola coffee shop

A demo coffee shop built to properly implement a full Stripe payment flow — webhooks, order fulfillment, and payment status handling.

AWS Cognito handles auth with email verification, and the cart persists in local storage before sign in.

Wrote a Medium article on the Stripe + Next.js integration.

  • typescript
  • next.js
  • redux
  • stripe
  • scss modules
  • aws
denizweber.com screenshot

denizweber.com

Freelance project for a professional book translator.

Translated works archive, publisher testimonials, and service listings — with SEO-optimized pages.

Built with Sanity CMS so the client can manage her own content without touching code.

  • typescript
  • next.js
  • sanity
  • tailwind

articles

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

Integrating 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 you enjoyed this tutorial and found it helpful for setting up Stripe in your Next.js app. If you have any questions or run into any issues, feel free to reach out to me at bulutyerli.com or leave a comment. Happy coding, and best of luck with your projects! 😊 Stackademic 🎓 Thank you for reading until the end. Before you go: Please consider clapping and following the writer! 👏 Follow us X | LinkedIn | YouTube | Discord Visit our other platforms: In Plain English | CoFeed | Differ More content at Stackademic.com Integrating Stripe Payment Elements with Next.js 14 App Router & Webhooks & Typescript was originally published in Stackademic on Medium, where people are continuing the conversation by highlighting and responding to this story.