Skip to content

Stripe Optional

stripenextjs

Setup

Stripe requires these environment variables. The STRIPE_SECRET_KEY is here and please create a product to get the STRIPE_PRICE_ID. If you run stripe on your local, you will get the STRIPE_WEBHOOK_SECRET after running stripe CLI.

STRIPE_SECRET_KEY=
STRIPE_WEBHOOK_SECRET=
STRIPE_PRICE_ID=

Please install stripe CLI to bypass the webhook. Webhook is necessary to receive events such as configuration changes on Stripe and subscription expirations. Since subscriptions are managed on the application side, consistency within the app is maintained, but please understand that they are necessary to consider changes on Stripe.

After installing CLI, execute the following command in another terminal

sh
$ stripe listen --forward-to localhost:3000/api/payment/webhook

Flow

Checkout

The user requests the Server Function to create a Stripe session_id for subscription onClick. Stripe returns the URL of the official purchase page, so the Server Function redirects and the user is taken to the purchase page.

When you check out on the Stripe purchase page, Stripe will redirect you to the endpoint (payment/success) provided by our side with the session_id as a query parameter. The endpoint validates the session_id is correct or not, and then set the stripe_id to User model and insert the subscription info to Subscription model. Finally, the endpoint redirects to /me/payment page.

stripe checkout flow

See Full Code
tsx
"use client";

import { useTransition } from "react";
import { checkout, update } from "../_actions/payment";
import { Button } from "./Button";

type Props = {
  hasSubscription: boolean;
  cancelAtPeriodEnd: boolean;
};

export function PaymentButton({ hasSubscription, cancelAtPeriodEnd }: Props) {
  const [isPending, startTransition] = useTransition();

  const onClick = () => {
    startTransition(async () => {
      const { success } = hasSubscription
        ? await update(!cancelAtPeriodEnd)
        : await checkout();

      if (!success) {
        alert("internal error");
      }
    });
  };

  return (
    <Button
      onClick={onClick}
      disabled={isPending}
      className="border border-gray-300 rounded"
    >
      {hasSubscription ? (cancelAtPeriodEnd ? "Resume" : "Cancel") : "Checkout"}
    </Button>
  );
}
ts
"use server";

import type { Subscription } from "@prisma/client";
import { revalidatePath } from "next/cache";
import { redirect } from "next/navigation";
import { prisma } from "../_clients/prisma";
import { cancelUrl, stripe, successUrl } from "../_clients/stripe";
import { handleSubscriptionUpsert } from "../_utils/payment";
import { getSessionOrReject } from "./auth";
import type { Result } from "./types";

export async function checkout(): Promise<Result> {
  const session = await getSessionOrReject();

  if (!session.success) {
    return session;
  }

  const me = await prisma.user.findUnique({
    where: {
      id: session.data.user.id,
    },
  });

  if (!me?.email) {
    return {
      success: false,
      message: "user not found",
    };
  }

  const customer = await stripe.customers.create({
    email: me.email,
    name: me.name ?? "-",
  });
  const checkoutSession = await stripe.checkout.sessions.create({
    mode: "subscription",
    line_items: [
      {
        price: process.env.STRIPE_PRICE_ID,
        quantity: 1,
      },
    ],
    automatic_tax: {
      enabled: true,
    },
    customer: customer.id,
    customer_update: {
      // for automatic_tax
      shipping: "auto",
    },
    shipping_address_collection: {
      // for automatic_tax
      allowed_countries: ["JP"],
    },
    currency: "jpy",
    success_url: successUrl,
    cancel_url: cancelUrl,
  });

  await prisma.user.update({
    where: {
      id: me.id,
    },
    data: {
      stripeId: customer.id,
    },
  });

  if (!checkoutSession.url) {
    return {
      success: false,
      message: "checkoutSession url not found",
    };
  }

  redirect(checkoutSession.url);
}

type ReturnedUpdate = Result<
  Pick<
    Subscription,
    "subscriptionId" | "currentPeriodEnd" | "cancelAtPeriodEnd"
  >
>;

// if you set multiple subscriptions, you need to pass the subscriptionId as an argument
export async function update(
  cancelAtPeriodEnd: boolean,
): Promise<ReturnedUpdate> {
  const { success, data } = await status();

  if (!success || !data) {
    return {
      success: false,
      message: "subscription not found",
    };
  }

  try {
    const subscription = await stripe.subscriptions.update(
      data.subscriptionId,
      {
        cancel_at_period_end: cancelAtPeriodEnd,
      },
    );
    const res = await handleSubscriptionUpsert(subscription);

    if (!res) {
      throw new Error("subscription update failed");
    }

    revalidatePath("/me/payment");

    return {
      success: true,
      data: {
        subscriptionId: res.subscriptionId,
        currentPeriodEnd: res.currentPeriodEnd,
        cancelAtPeriodEnd: res.cancelAtPeriodEnd,
      },
    };
  } catch {
    return {
      success: false,
      message: "subscription update failed",
    };
  }
}

type ReturnedStatus = Result<Pick<
  Subscription,
  "subscriptionId" | "cancelAtPeriodEnd" | "currentPeriodEnd"
> | null>;

// this sample code assumes that the user has only one subscription
export async function status(): Promise<ReturnedStatus> {
  const session = await getSessionOrReject();

  if (!session.success) {
    return session;
  }

  const { user } = session.data;
  const subscription = await prisma.subscription.findFirst({
    where: {
      userId: user.id,
      status: {
        in: ["active", "complete"],
      },
    },
  });

  if (!subscription) {
    return {
      success: true,
      data: null,
      message: "subscription not found",
    };
  }

  return {
    success: true,
    data: {
      subscriptionId: subscription.subscriptionId,
      cancelAtPeriodEnd: subscription.cancelAtPeriodEnd,
      currentPeriodEnd: subscription.currentPeriodEnd,
    },
  };
}
ts
import { redirect } from "next/navigation";
import { type NextRequest, NextResponse } from "next/server";
import { getSessionOrReject } from "../../../_actions/auth";
import { prisma } from "../../../_clients/prisma";
import { stripe } from "../../../_clients/stripe";

export async function GET(req: NextRequest) {
  const sessionId = req.nextUrl.searchParams.get("session_id");

  if (!sessionId) {
    return new NextResponse("Invalid session_id", { status: 400 });
  }

  const session = await getSessionOrReject();

  if (!session.success) {
    return new NextResponse(session.message, { status: 401 });
  }

  const { user } = session.data;
  const paymentInfo = await stripe.checkout.sessions.retrieve(sessionId);

  if (paymentInfo.status === "complete") {
    try {
      await prisma.$transaction(async (prisma) => {
        await prisma.user.update({
          where: {
            id: user.id,
          },
          data: {
            stripeId: `${paymentInfo.customer}`,
            subscriptions: {
              create: {
                subscriptionId: `${paymentInfo.subscription}`,
                status: `${paymentInfo.status}`,
              },
            },
          },
        });
      });
    } catch {
      // session is expired
      redirect("/me/payment?status=incomplete");
    }

    redirect("/me/payment");
  } else {
    redirect("/me/payment?status=incomplete");
  }
}

This template doesn't provide a custom payment screen so if you want to create it, please change the redirected endpoint. And also cancel screen as well.

Cancel/Resume a Subscription

If users want to cancel a subscription, you change the cancel_at_period_end param. Strip will cancel the subscription when the deadline expires. (see next section)

stripe cancel/resume flow

See Full Code
tsx
import Link from "next/link";
import { notFound } from "next/navigation";
import { status } from "../_actions/payment";
import { format } from "../_utils/date";
import { PaymentButton } from "./PaymentButton";

export async function Payment() {
  const { success, message, data } = await status();
  const limitDate = data?.cancelAtPeriodEnd ? data?.currentPeriodEnd : null;

  if (message === "no session token") {
    notFound();
  }

  return (
    <div className="flex flex-col items-center gap-10">
      <h1 className="font-semibold text-lg">Subscription Status</h1>
      {data?.subscriptionId && <p className="text-sm">{data.subscriptionId}</p>}
      {!success ? (
        <p className="text-red-300">internal error</p>
      ) : (
        <PaymentButton
          hasSubscription={!!data}
          cancelAtPeriodEnd={!!data?.cancelAtPeriodEnd}
        />
      )}
      {limitDate && (
        <p className="text-sm text-gray-400">
          Available until {format(limitDate)}
        </p>
      )}
      <Link
        href="https://docs.stripe.com/testing#cards"
        referrerPolicy="no-referrer"
        target="_blank"
        className="underline"
      >
        DEBUG: Test card numbers
      </Link>
    </div>
  );
}
ts
"use server";

import type { Subscription } from "@prisma/client";
import { revalidatePath } from "next/cache";
import { redirect } from "next/navigation";
import { prisma } from "../_clients/prisma";
import { cancelUrl, stripe, successUrl } from "../_clients/stripe";
import { handleSubscriptionUpsert } from "../_utils/payment";
import { getSessionOrReject } from "./auth";
import type { Result } from "./types";

export async function checkout(): Promise<Result> {
  const session = await getSessionOrReject();

  if (!session.success) {
    return session;
  }

  const me = await prisma.user.findUnique({
    where: {
      id: session.data.user.id,
    },
  });

  if (!me?.email) {
    return {
      success: false,
      message: "user not found",
    };
  }

  const customer = await stripe.customers.create({
    email: me.email,
    name: me.name ?? "-",
  });
  const checkoutSession = await stripe.checkout.sessions.create({
    mode: "subscription",
    line_items: [
      {
        price: process.env.STRIPE_PRICE_ID,
        quantity: 1,
      },
    ],
    automatic_tax: {
      enabled: true,
    },
    customer: customer.id,
    customer_update: {
      // for automatic_tax
      shipping: "auto",
    },
    shipping_address_collection: {
      // for automatic_tax
      allowed_countries: ["JP"],
    },
    currency: "jpy",
    success_url: successUrl,
    cancel_url: cancelUrl,
  });

  await prisma.user.update({
    where: {
      id: me.id,
    },
    data: {
      stripeId: customer.id,
    },
  });

  if (!checkoutSession.url) {
    return {
      success: false,
      message: "checkoutSession url not found",
    };
  }

  redirect(checkoutSession.url);
}

type ReturnedUpdate = Result<
  Pick<
    Subscription,
    "subscriptionId" | "currentPeriodEnd" | "cancelAtPeriodEnd"
  >
>;

// if you set multiple subscriptions, you need to pass the subscriptionId as an argument
export async function update(
  cancelAtPeriodEnd: boolean,
): Promise<ReturnedUpdate> {
  const { success, data } = await status();

  if (!success || !data) {
    return {
      success: false,
      message: "subscription not found",
    };
  }

  try {
    const subscription = await stripe.subscriptions.update(
      data.subscriptionId,
      {
        cancel_at_period_end: cancelAtPeriodEnd,
      },
    );
    const res = await handleSubscriptionUpsert(subscription);

    if (!res) {
      throw new Error("subscription update failed");
    }

    revalidatePath("/me/payment");

    return {
      success: true,
      data: {
        subscriptionId: res.subscriptionId,
        currentPeriodEnd: res.currentPeriodEnd,
        cancelAtPeriodEnd: res.cancelAtPeriodEnd,
      },
    };
  } catch {
    return {
      success: false,
      message: "subscription update failed",
    };
  }
}

type ReturnedStatus = Result<Pick<
  Subscription,
  "subscriptionId" | "cancelAtPeriodEnd" | "currentPeriodEnd"
> | null>;

// this sample code assumes that the user has only one subscription
export async function status(): Promise<ReturnedStatus> {
  const session = await getSessionOrReject();

  if (!session.success) {
    return session;
  }

  const { user } = session.data;
  const subscription = await prisma.subscription.findFirst({
    where: {
      userId: user.id,
      status: {
        in: ["active", "complete"],
      },
    },
  });

  if (!subscription) {
    return {
      success: true,
      data: null,
      message: "subscription not found",
    };
  }

  return {
    success: true,
    data: {
      subscriptionId: subscription.subscriptionId,
      cancelAtPeriodEnd: subscription.cancelAtPeriodEnd,
      currentPeriodEnd: subscription.currentPeriodEnd,
    },
  };
}

Updated Events by Stripe

This template is set up so that the subscription will not be canceled immediately even if a user unsubscribes. Therefore, when the deadline comes, this server will receive a notification from Stripe and then update the database, so needs to provide the webhook endpoint to Stripe.

The cases assumed by this template are

  • When the stripe user is deleted
  • When the subscription expires
  • When the subscription status changes

If you want to reload the user's page when receive a notification from Stripe, you need to implement Polling, Server-Sent Events, WebSocket, etc.

stripe webhook flow

See Full Code
ts
import { NextResponse } from "next/server";
import type Stripe from "stripe";
import { prisma } from "../../../_clients/prisma";
import { stripe } from "../../../_clients/stripe";
import { handleSubscriptionUpsert } from "../../../_utils/payment";

export async function POST(req: Request) {
  const sig = req.headers.get("stripe-signature");

  if (!sig) {
    return new NextResponse("Missing stripe-signature", { status: 400 });
  }

  let rawBody: Buffer;

  try {
    rawBody = Buffer.from(await req.arrayBuffer());
  } catch (err) {
    return new NextResponse("Error reading request body", { status: 400 });
  }

  let event: Stripe.Event;

  try {
    event = stripe.webhooks.constructEvent(
      rawBody,
      sig,
      process.env.STRIPE_WEBHOOK_SECRET,
    );
  } catch (err) {
    return new NextResponse("Webhook signature verification failed", {
      status: 400,
    });
  }

  switch (event.type) {
    case "customer.subscription.created":
    case "customer.subscription.deleted":
    case "customer.subscription.updated": {
      const subscription = event.data.object as Stripe.Subscription;

      try {
        await handleSubscriptionUpsert(subscription);
      } catch {
        return new NextResponse("Error upserting subscription", {
          status: 500,
        });
      }
      break;
    }

    case "customer.deleted": {
      const customer = event.data.object as Stripe.Customer;

      try {
        await prisma.$transaction(async (prisma) => {
          await prisma.user.update({
            where: {
              stripeId: customer.id,
            },
            data: {
              stripeId: null,
              subscriptions: {
                deleteMany: {},
              },
            },
          });
        });
      } catch {
        return new NextResponse("Error deleting customer", {
          status: 500,
        });
      }

      break;
    }

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

  return new NextResponse("received", { status: 200 });
}