Type-safe Payments with Next.js, TypeScript, and Stripe ππΈ
Thor ι·η₯
Posted on February 11, 2020
- Demo: https://nextjs-typescript-react-stripe-js.now.sh/
- Code: https://github.com/vercel/next.js/tree/canary/examples/with-stripe-typescript
- CodeSandbox: https://codesandbox.io/s/github/stripe-samples/nextjs-typescript-react-stripe-js
Table of Contents
- Setting up a TypeScript project with Next.js
- Managing API keys/secrets with Next.js & Vercel
- Stripe.js loading utility for ESnext applications
- Handling custom amount input from the client-side
- Format currencies for display and detect zero-decimal currencies
- The useStripe Hook
- Creating a CheckoutSession and redirecting to Stripe Checkout
- Taking card details on-site with Stripe Elements & PaymentIntents
- Handling Webhooks & checking their signatures
- Deploy it to the cloud with Vercel
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
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!);
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;
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;
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;
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.");
}
// ...
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);
}
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
);
// ...
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);
};
// ...
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;
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);
// ...
// 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
);
// ...
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);
}
};
// ...
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);
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)
// ...
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:
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 π₯³
Posted on February 11, 2020
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.