Skip to content

How to Integrate Stripe Payment Gateway in Next.js

Published: at 07:59 AM

As a beginner, Stripe documentation can give me a headache. When I tried to set it up for the first time, I got confused. Luckily, I managed to figure it out, and here I will make it short and understandable. I will use Next.js for this tutorial.

Table of contents

Open Table of contents

1. Setup Stripe Account

You need to create a Stripe account to get a few keys and put those in our .env file. Then, head over to the dashboard and hit the Developers tab.

// file: .env
STRIPE_SECRET_KEY = yoursecretkey;

grab-stripe-secret-key

Make sure you’re in test mode because you need to complete your business profile to proceed to live mode.

2. Understanding Stripe Checkout Flow

To complete a Stripe payment, we should understand the basics of the checkout session flow. Basically, we need an item, display it to the Stripe checkout session URL, and create an endpoint for the webhook event. I’ll cover the Stripe webhook event in the next post.

stripe-checkout-session-flow

For the database, there should be at least five tables, which are:

  1. User
  2. Order
  3. Item
  4. BillingAddress
  5. ShippingAddress

The billing address and shipping address are similar, but they’re different. The billing address means payment verification and confirmation during online transactions, while the shipping address means delivery of purchased products to a specified location.

3. Integrate

I use Prisma as ORM because it makes the code easier to read, at least for me. Also, I use @tanstack/react-query for fetching, caching, synchronizing, and updating the server state. This is how you can install it inside a Next.js project:

npm install prisma @prisma/client @tanstack/react-query

Then execute:

npx prisma init

This will create a new folder called Prisma, and in the schema.prisma file, you can write like this:

// file: prisma/schema.prisma

enum OrderStatus {
  fulfilled
  shipped
  awaiting_shipment
}

model User {
  id        String  @id @default(cuid())
  email     String
  Order     Order[]
  createdAt DateTime @default(now())
  updatedAt DateTime? @updatedAt
}

model Item {
  id              String        @id @default(cuid())
  name            String
  Order           Order[]
  userId          String
}

model Order {
  id                String           @id @default(cuid())
  itemId            String
  item              Item    @relation(fields: [itemId], references: [id])
  user              User             @relation(fields: [userId], references: [id])
  userId            String
  amount            Float
  isPaid            Boolean          @default(false)
  status            OrderStatus      @default(awaiting_shipment)
  shippingAddress   ShippingAddress? @relation(fields: [shippingAddressId], references: [id])
  shippingAddressId String?
  billingAddress    BillingAddress?  @relation(fields: [billingAddressId], references: [id])
  billingAddressId  String?

  createdAt DateTime @default(now())
  updated   DateTime @updatedAt
}

model ShippingAddress {
  id          String  @id @default(cuid())
  name        String
  street      String
  city        String
  postalCode  String
  country     String
  state       String?
  phoneNumber String?
  orders      Order[]
}

model BillingAddress {
  id          String  @id @default(cuid())
  name        String
  street      String
  city        String
  postalCode  String
  country     String
  state       String?
  phoneNumber String?
  orders      Order[]
}

Then we need to instantiate Prisma to make sure it just run once and create a new folder inside the src folder called db so we can make queries later on:

// file: src/db/index.ts
import { PrismaClient } from "@prisma/client";

declare global {
  var cachedPrisma: PrismaClient;
}

let prisma: PrismaClient;

if (process.env.NODE_ENV === "production") {
  prisma = new PrismaClient();
} else {
  if (!global.cachedPrisma) {
    global.cachedPrisma = new PrismaClient();
  }

  prisma = global.cachedPrisma;
}

export const db = prisma;

One more thing. We will use server actions to create a checkout session. So, create one more file inside the route folder called action.ts, and inside it, we want to write logic for price, existing orders, and generating session URLs.

// file: src/app/checkout/action.ts
"use server";

import { db } from "@/db";
import { stripe } from "@/lib/stripe";
import { Order } from "@prisma/client";
import { getKindeServerSession } from "@kinde-oss/kinde-auth-nextjs/server";

export const createCheckoutSession = async ({
  itemId,
}: {
  itemId: string;
}) => {
  const item = await db.item.findUnique({
    where: { id: itemId },
  });

  if (!item) {
    throw new Error("No such item found");
  }

  // logic for auth check
  const { getUser } = getKindeServerSession()
  const user = await getUser()

  if(!user) {
    throw new Error("You need to be logged in");
  }

  // you can write logic for determine price here

  let order: Order | undefined = undefined

  const existingOrder = await db.order.findFirst({
    where: {
      userId: user.id,
      itemId
    }
  })


  if(existingOrder) {
    order = existingOrder
  } else {
    order = await db.order.create({
      data: {
        amount: // your price,
        userId: user.id,
        itemId,
      }
    })
  }

  const product = await stripe.products.create({
    name: "YOUR PRODUCT",
    images: // your image product URL,
    default_price_data: {
      currency: "USD",
      unit_amount: // your price,
    }
  })

  const stripeSession = await stripe.checkout.sessions.create({
    success_url: // your url if the user complete the payment
    cancel_url: // your url if the user cancel their payment
    payment_method_types: ["card", "link"],
    mode: "payment",
    shipping_address_collection: {
      allowed_countries: ["US", "CA", "ID", "DE"]
    },
    metadata: {
      userId: user.id,
      orderId: order.id
    },
    line_items: [{ price: product.default_price as string, quantity: 1 }],
  })

  return { url: stripeSession.url }
};

After that, we’re ready to create a checkout session from the client. You can put this code on any page that has the last step for payment or summary. The payment checkout session URL will be redirected if createPaymentSession is triggered. This is usually a checkout button or submit button. Here it is:

// file: src/app/checkout/page.tsx

import { useMutation } from "@tanstack/react-query";
import { createCheckoutSession } from "./action";
import { ArrowRight } from "lucide-react";

  const Page = () => {

  const { mutate: createPaymentSession } = useMutation({
    mutationKey: ["get-checkout-session"],
    mutationFn: createCheckoutSession,
    onSuccess: ({ url }) => {
      if (url) router.push(url);
      else throw new Error("Unable to retrieve payment URL.");
    },
    onError: () => {
      // you can trigger toast or notification right here
      console.error("Something went wrong!")
    },
  });

  // example checkout button
  return (
    <div>
       <Button
          onClick={() => createPaymentSession("ITEM ID")}
          className="px-4 sm:px-6 lg:px-8"
          >
          Check out <ArrowRight className="h-4 w-4 ml-1.5 inline" />
       </Button>
    </div>
  )
  }

If you run the code and trigger createPaymentSession, it will redirect to a dedicated page from Stripe:

stripe-checkout-session-page

That’s it. If you check your Stripe payment history, it will be recorded when you’re creating the checkout session. If it’s successfully paid, it will trigger our webhook endpoint and update the payment status, address, etc. I will cover this in the next post.

See you in the next post :)