DEV Community

Thor 雷神 for Stripe

Posted on • Updated on

Type-safe Payments with Next.js, TypeScript, and Stripe 🔒💸

Table of Contents

In the 2019 StackOverflow survey, TypeScript has gained a lot of popularity, moving into the top ten of the most popular and most loved languages.

As of version 8.0.1, Stripe maintains types for the latest API version, giving you type errors, autocompletion for API fields and params, in-editor documentation, and much more!

To support this great developer experience across the stack, Stripe has also added types to the react-stripe-js library, which additionally follows the hooks pattern, to enable a delightful and modern developer experience. Friendly Canadian Fullstack Dev Wes Bos has called it "awesome" and has already moved his Advanced React course over to it, and I hope you will also enjoy this delightful experience soon 🙂

Please do tweet at me with your questions and feedback!

Setting up a TypeScript project with Next.js

Setting up a TypeScript project with Next.js is quite convenient, as it automatically generates the tsconfig.json configuration file for us. You can follow the setup steps in the docs or start off with a more complete example. Of course you can also find the full example that we're looking at in detail below, on GitHub.

Managing API keys/secrets with Next.js & Vercel

When working with API keys and secrets, we need to make sure we keep them secret and out of version control (make sure to add .env*.local to your .gitignore file) while conveniently making them available as env variables. Find more details about environment variables in the Netx.js docs.

At the root of our project we add a .env.local file and provide the Stripe keys and secrets from our Stripe Dashboard:

# Stripe keys
NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=pk_12345
STRIPE_SECRET_KEY=sk_12345
Enter fullscreen mode Exit fullscreen mode

The NEXT_PUBLIC_ prefix automatically exposes this variable to the browser. Next.js will insert the value for these into the publicly viewable source code at build/render time. Therefore make sure to not use this prefix for secret values!

Stripe.js loading utility for ESnext applications

Due to PCI compliance requirements, the Stripe.js library has to be loaded from Stripe's servers. This creates a challenge when working with server-side rendered apps, as the window object is not available on the server. To help you manage that complexity, Stripe provides a loading wrapper that allows you to import Stripe.js like an ES module:

import { loadStripe } from '@stripe/stripe-js';

const stripe = await loadStripe(process.env.NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY!);
Enter fullscreen mode Exit fullscreen mode

Stripe.js is loaded as a side effect of the import '@stripe/stripe-js'; statement. To best leverage Stripe’s advanced fraud functionality, ensure that Stripe.js is loaded on every page of your customer's checkout journey, not just your checkout page. This allows Stripe to detect anomalous behavior that may be indicative of fraud as customers browse your website.

To make sure Stripe.js is loaded on all relevant pages, we create a Layout component that loads and initialises Stripe.js and wraps our pages in an Elements provider so that it is available everywhere we need it:

// Partial of components/Layout.tsx
// ...
import { Elements } from '@stripe/react-stripe-js';
import { loadStripe } from '@stripe/stripe-js';

type Props = {
  title?: string;
};

const stripePromise = loadStripe(process.env.NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY!);

const Layout: React.FunctionComponent<Props> = ({
  children,
  title = 'TypeScript Next.js Stripe Example'
}) => (
  <Elements stripe={stripePromise}>
    <Head>
    {/* ... */}
    </footer>
  </Elements>
);

export default Layout;
Enter fullscreen mode Exit fullscreen mode

Handling custom amount input from the client-side

The reason why we generally need a server-side component to process payments is that we can't trust the input that is posted from the frontend. E.g. someone could open up the browser dev tools and modify the amount that the frontend sends to the backend. There always needs to be some server-side component to calculate/validate the amount that should be charged.

If you operate a pure static site (did someone say JAMstack?!), you can utilise Stripe's client-only Checkout functionality. In this we create our product or subscription plan details in Stripe, so that Stripe can perform the server-side validation for us. You can see some examples of this using Gatsby on my GitHub.

Back to the topic at hand: in this example, we want to allow customers to specify a custom amount that they want to donate, however we want to set some limits, which we specify in /config/index.ts:

export const CURRENCY = 'usd';
// Set your amount limits: Use float for decimal currencies and
// Integer for zero-decimal currencies: https://stripe.com/docs/currencies#zero-decimal.
export const MIN_AMOUNT = 10.0;
export const MAX_AMOUNT = 5000.0;
export const AMOUNT_STEP = 5.0;
Enter fullscreen mode Exit fullscreen mode

With Next.js we can conveniently use the same config file for both our client-side and our server-side (API route) components. On the client we create a custom amount input field component which is defined in /components/CustomDonationInput.tsx and can be used like this:

// Partial of ./components/CheckoutForm.tsx
// ...
  return (
    <form onSubmit={handleSubmit}>
      <CustomDonationInput
        name={"customDonation"}
        value={input.customDonation}
        min={config.MIN_AMOUNT}
        max={config.MAX_AMOUNT}
        step={config.AMOUNT_STEP}
        currency={config.CURRENCY}
        onChange={handleInputChange}
      />
      <button type="submit">
        Donate {formatAmountForDisplay(input.customDonation, config.CURRENCY)}
      </button>
    </form>
  );
};

export default CheckoutForm;
Enter fullscreen mode Exit fullscreen mode

In our server-side component, we then validate the amount that was posted from the client:

// Partial of ./pages/api/checkout_sessions/index.ts
// ...
export default async (req: NextApiRequest, res: NextApiResponse) => {
  if (req.method === "POST") {
    const amount: number = req.body.amount;
    try {
      // Validate the amount that was passed from the client.
      if (!(amount >= MIN_AMOUNT && amount <= MAX_AMOUNT)) {
        throw new Error("Invalid amount.");
      }
// ...
Enter fullscreen mode Exit fullscreen mode

Format currencies for display and detect zero-decimal currencies

In JavaScript we can use the Intl.Numberformat constructor to correctly format amounts and currency symbols, as well as detect zero-Decimal currencies using the formatToParts method. For this we create some helper methods in ./utils/stripe-helpers.ts:

export function formatAmountForDisplay(
  amount: number,
  currency: string
): string {
  let numberFormat = new Intl.NumberFormat(['en-US'], {
    style: 'currency',
    currency: currency,
    currencyDisplay: 'symbol',
  });
  return numberFormat.format(amount);
}

export function formatAmountForStripe(
  amount: number,
  currency: string
): number {
  let numberFormat = new Intl.NumberFormat(['en-US'], {
    style: 'currency',
    currency: currency,
    currencyDisplay: 'symbol',
  });
  const parts = numberFormat.formatToParts(amount);
  let zeroDecimalCurrency: boolean = true;
  for (let part of parts) {
    if (part.type === 'decimal') {
      zeroDecimalCurrency = false;
    }
  }
  return zeroDecimalCurrency ? amount : Math.round(amount * 100);
}
Enter fullscreen mode Exit fullscreen mode

The useStripe Hook

As part of the react-stripe-js library, Stripe provides hooks (e.g. useStripe, useElements) to retrieve references to the stripe and elements instances.

If you're unfamiliar with the concept of Hooks in React, I recommend briefly glancing at "Hooks at a Glance".

Creating a CheckoutSession and redirecting to Stripe Checkout

Stripe Checkout is the fastest way to get started with Stripe and provides a stripe-hosted checkout page that comes with various payment methods and support for Apple Pay and Google Pay out of the box.

In our checkout_session API route we create a CheckoutSession with the custom donation amount:

// Partial of ./pages/api/checkout_sessions/index.ts
// ...
// Create Checkout Sessions from body params.
const params: Stripe.Checkout.SessionCreateParams = {
  submit_type: 'donate',
  payment_method_types: ['card'],
  line_items: [
    {
      name: 'Custom amount donation',
      amount: formatAmountForStripe(amount, CURRENCY),
      currency: CURRENCY,
      quantity: 1,
    },
  ],
  success_url: `${req.headers.origin}/result?session_id={CHECKOUT_SESSION_ID}`,
  cancel_url: `${req.headers.origin}/result?session_id={CHECKOUT_SESSION_ID}`,
};
const checkoutSession: Stripe.Checkout.Session = await stripe.checkout.sessions.create(
  params
);
// ...
Enter fullscreen mode Exit fullscreen mode

In our client-side component, we then use the CheckoutSession id to redirect to the Stripe hosted page:

// Partial of ./components/CheckoutForm.tsx
// ...
const handleSubmit = async (e: FormEvent) => {
  e.preventDefault();
  // Create a Checkout Session.
  const checkoutSession: Stripe.Checkout.Session = await fetchPostJSON(
    '/api/checkout_sessions',
    { amount: input.customDonation }
  );

  if ((checkoutSession as any).statusCode === 500) {
    console.error((checkoutSession as any).message);
    return;
  }

  // Redirect to Checkout.
  const { error } = await stripe.redirectToCheckout({
    // Make the id field from the Checkout Session creation API response
    // available to this file, so you can provide it as parameter here
    // instead of the {{CHECKOUT_SESSION_ID}} placeholder.
    sessionId: checkoutSession.id,
  });
  // If `redirectToCheckout` fails due to a browser or network
  // error, display the localized error message to your customer
  // using `error.message`.
  console.warn(error.message);
};
// ...
Enter fullscreen mode Exit fullscreen mode

Once the customer has completed (or canceled) the payment on the Stripe side, they will be redirected to our /pages/result.tsx page. Here we use the useRouter hook to access the CheckoutSession id, that was appended to our URL, to retrieve and print the CheckoutSession object.

Since we're using TypeScript, we can use some awesome ESnext language features like optional chaining and the nullish coalescing operator that are (at the time of writing) not yet available within JavaScript.

// Partial of ./pages/result.tsx
// ...
const ResultPage: NextPage = () => {
  const router = useRouter();

  // Fetch CheckoutSession from static page via
  // https://nextjs.org/docs/basic-features/data-fetching#static-generation
  const { data, error } = useSWR(
    router.query.session_id
      ? `/api/checkout_sessions/${router.query.session_id}`
      : null,
    fetchGetJSON
  );

  if (error) return <div>failed to load</div>;

  return (
    <Layout title="Checkout Payment Result | Next.js + TypeScript Example">
      <h1>Checkout Payment Result</h1>
      <h2>Status: {data?.payment_intent?.status ?? 'loading...'}</h2>
      <p>
        Your Checkout Session ID:{' '}
        <code>{router.query.session_id ?? 'loading...'}</code>
      </p>
      <PrintObject content={data ?? 'loading...'} />
      <p>
        <Link href="/">
          <a>Go home</a>
        </Link>
      </p>
    </Layout>
  );
};

export default ResultPage;
Enter fullscreen mode Exit fullscreen mode

Taking card details on-site with Stripe Elements & PaymentIntents

Stripe Elements are a set of prebuilt UI components that allow for maximum customisation and control of your checkout flows. You can find a collection of examples for inspiration on GitHub.

React Stripe.js is a thin wrapper around Stripe Elements. It allows us to add Elements to our React application.

Above when setting up our Layout component, we've seen how to load Stripe and wrap our application in the Elements provider, allowing us to use the Stripe Elements components in any pages that use this Layout.

In this example we're using the default PaymentIntents integration, which will confirm our payment client-side. Therefore, once the user submits the form, we will first need to create a PaymentIntent in our API route:

// Partial of ./components/ElementsForm.tsx
// ...
const handleSubmit: React.FormEventHandler<HTMLFormElement> = async e => {
    e.preventDefault();
    setPayment({ status: 'processing' });

    // Create a PaymentIntent with the specified amount.
    const response = await fetchPostJSON('/api/payment_intents', {
      amount: input.customDonation
    });
    setPayment(response);
// ...
Enter fullscreen mode Exit fullscreen mode
// Partial of ./pages/api/payment_intents/index.ts
// ...
// Validate the amount that was passed from the client.
if (!(amount >= MIN_AMOUNT && amount <= MAX_AMOUNT)) {
  throw new Error('Invalid amount.');
}
// Create PaymentIntent from body params.
const params: Stripe.PaymentIntentCreateParams = {
  payment_method_types: ['card'],
  amount: formatAmountForStripe(amount, CURRENCY),
  currency: CURRENCY,
};
const payment_intent: Stripe.PaymentIntent = await stripe.paymentIntents.create(
  params
);
// ...
Enter fullscreen mode Exit fullscreen mode

The PaymentIntent will provide a client_secret which we can use to finalise the payment on the client using Stripe.js. This allows Stripe to automatically handle additional payment activation requirements like authentication with 3D Secure, which is crucial for accepting payments in regions like Europe and India.

// Partial of ./components/ElementsForm.tsx
// ...
 // Get a reference to a mounted CardElement. Elements knows how
    // to find your CardElement because there can only ever be one of
    // each type of element.
    const cardElement = elements!.getElement(CardElement);

    // Use the card Element to confirm the Payment.
    const { error, paymentIntent } = await stripe!.confirmCardPayment(
      response.client_secret,
      {
        payment_method: {
          card: cardElement!,
          billing_details: { name: input.cardholderName }
        }
      }
    );

    if (error) {
      setPayment({ status: 'error' });
      setErrorMessage(error.message ?? 'An unknown error occured');
    } else if (paymentIntent) {
      setPayment(paymentIntent);
    }
  };
// ...
Enter fullscreen mode Exit fullscreen mode

NOTE that confirming the payment client-side means that we will need to handle post-payment events. In this example we'll be implementing a webhook handler in the next step.

Handling Webhooks & checking their signatures

Webhook events allow us to automatically get notified about events that happen on our Stripe account. This is especially useful when utilising asynchronous payments, subscriptions with Stripe Billing, or building a marketplace with Stripe Connect.

By default Next.js API routes are same-origin only. To allow Stripe webhook event requests to reach our API route, we need to add micro-cors:

// Partial of ./pages/api/webhooks/index.ts
import Cors from 'micro-cors';

const cors = Cors({
  allowMethods: ['POST', 'HEAD'],
});
// ...
export default cors(webhookHandler as any);
Enter fullscreen mode Exit fullscreen mode

This, however, means that now anyone can post requests to our API route. To make sure that a webhook event was sent by Stripe, not by a malicious third party, we need to verify the webhook event signature:

// Partial of ./pages/api/webhooks/index.ts
// ...
const webhookSecret: string = process.env.STRIPE_WEBHOOK_SECRET!

// Stripe requires the raw body to construct the event.
export const config = {
  api: {
    bodyParser: false,
  },
}

const webhookHandler = async (req: NextApiRequest, res: NextApiResponse) => {
  if (req.method === 'POST') {
    const buf = await buffer(req)
    const sig = req.headers['stripe-signature']!

    let event: Stripe.Event

    try {
      event = stripe.webhooks.constructEvent(buf.toString(), sig, webhookSecret)
    } catch (err) {
      // On error, log and return the error message
      console.log(`❌ Error message: ${err.message}`)
      res.status(400).send(`Webhook Error: ${err.message}`)
      return
    }

    // Successfully constructed event
    console.log('✅ Success:', event.id)
// ...
Enter fullscreen mode Exit fullscreen mode

This way our API route is able to receive POST requests from Stripe but also makes sure, only requests sent by Stripe are actually processed.

Deploy it to the cloud with Vercel

You can deploy this example by clicking the "Deploy to Vercel" button below. It will guide you through the secrets setup and create a fresh repository for you:

Deploy to Vercel

From there you can clone the repository to your local machine, and anytime you commit/push/merge changes to master, Vercel will automatically redeploy the site for you 🥳

Top comments (7)

Collapse
 
regisnut profile image
Regisnut

Thks Thor, is it possible to let a user decide the amount and the frequency for a subscription??
I can do it with my revolut, I can decide the amount and add a frequency, hence it becomes a recurrent subscription.

Collapse
 
rchrdnsh profile image
rchrdnsh • Edited

This is great, although there is much I don’t understand... if you would consider doing a version of this tutorial for Gatsby/Netlify as well, and compare/contrast the two that would be amazing... also gonna look at the Lambda thing you posted a few comments down :-)

Collapse
 
trag profile image
Chris Traganos
Collapse
 
nejurgis profile image
Nejurgis

Thor, thank you for the post, could you maybe go into detail how would one create a payment intent with Gatsby? I need to create a custom payment form so using Stripe Checkout is not enough ..

Collapse
 
thorwebdev profile image
Thor 雷神

For this you will need a server-side component. This can be a Lambda/Netlify/Zeit function.

dabit3 image


has an example of this over at github.com/jamstack-cms/jamstack-e.... Hope that helps :)
Collapse
 
jamonholmgren profile image
Jamon Holmgren

This is fantastic -- thank you Thor!

Collapse
 
smakosh profile image
smakosh

Great article! Thanks for sharing, could you please include the part where we add webhooks to Stripe using Stripe CLI?