so just to start of with, this is my first question I've had to ask for on stackoverflow, so apologies in advance if I don't provide enough detail or forget to include certain details.
I have successfully implemented a custom payment flow using stripe's API in my next.js application. Everything seems to be working fine for the actual checkout, however when stripe redirects to the specified return_url, next.js' server & client have different URL parameters. This is due to stripe providing the URL parameters to the client when redirecting, but not the server, hence this error occurs:
Warning: Prop `href` did not match. Server: "/checkout#" Client: "/checkout?payment_intent=pi_3La0akIazEKeaf0C29fCT4rX&payment_intent_client_secret=pi_3La0akIazEKeaf0C29fCT4rX_secret_kzvRJcHab0DDdktG7EyCkDJPJ&redirect_status=succeeded#"
The included example stripe application does not produce this server/client mismatch error, the example can be found here: stripe custom payment flow application. There is no difference in the code used in my application compared to the example provided, they are 1:1 copies, besides the fact that the example uses the root of the project for it's return url, instead of a next.js page, which my app uses checkout.tsx.
I am not an experienced developer, so I don't know why on my app is giving me this error, when the example app does not produce the error (or I fail to reproduce it). I have researched this error extensively, and most solutions just execute the code on the client by checking if the window exists in next.js. However that doesn't work here since the stripe library itself is injecting the URL parameters through the return_url, and I have no control over what it injects, or how it is injected.
Here are the nextjs page & component used in the checkout:
checkout.tsx:
import React from "react";
import { loadStripe } from "@stripe/stripe-js";
import { Elements } from "@stripe/react-stripe-js";
import CheckoutForm from "../components/CheckoutForm";
// Make sure to call loadStripe outside of a component’s render to avoid
// recreating the Stripe object on every render.
// This is your test publishable API key.
const stripePromise = loadStripe(process.env.NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY);
export default function Checkout() {
const [clientSecret, setClientSecret] = React.useState("");
React.useEffect(() => {
// Create PaymentIntent as soon as the page loads
fetch("/api/create-payment-intent", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ items: [{ id: "xl-tshirt" }] }),
})
.then((res) => res.json())
.then((data) => setClientSecret(data.clientSecret));
}, []);
const appearance = {
theme: "flat" as const,
variables: {
fontFamily: ' "Gill Sans", sans-serif',
fontLineHeight: "1.5",
fontSizeBase: "16px",
borderRadius: "10px",
colorBackground: "#F6F8FA",
colorPrimaryText: "#262626",
},
rules: {
".Block": {
backgroundColor: "var(--colorBackground)",
boxShadow: "none",
padding: "1px",
},
".Input": {
padding: "10px",
fontSize: "14px",
},
".Tab": {
padding: "10px 12px 8px 12px",
border: "none",
},
".Tab:hover": {
border: "none",
boxShadow: "0px 1px 1px rgba(0, 0, 0, 0.03), 0px 3px 7px rgba(18, 42, 66, 0.04)",
},
".Tab--selected, .Tab--selected:focus, .Tab--selected:hover": {
border: "none",
backgroundColor: "#fff",
boxShadow: "0 0 0 1.5px var(--colorPrimaryText), 0px 1px 1px rgba(0, 0, 0, 0.03), 0px 3px 7px rgba(18, 42, 66, 0.04)",
},
".Label": {
fontWeight: "500",
},
},
};
const options = {
clientSecret,
appearance,
};
return (
<div className="App">
{clientSecret && (
<Elements options={options} stripe={stripePromise}>
<CheckoutForm />
</Elements>
)}
</div>
);
}
checkoutForm.tsx:
import React from "react";
import { PaymentElement, useStripe, useElements } from "@stripe/react-stripe-js";
export default function CheckoutForm() {
const stripe = useStripe();
const elements = useElements();
const [message, setMessage] = React.useState(null);
const [isLoading, setIsLoading] = React.useState(false);
React.useEffect(() => {
if (!stripe) {
return;
}
const clientSecret = new URLSearchParams(window.location.search).get("payment_intent_client_secret");
if (!clientSecret) {
return;
}
stripe.retrievePaymentIntent(clientSecret).then(({ paymentIntent }) => {
switch (paymentIntent.status) {
case "succeeded":
setMessage("Payment succeeded!");
break;
case "processing":
setMessage("Your payment is processing.");
break;
case "requires_payment_method":
setMessage("Your payment was not successful, please try again.");
break;
default:
setMessage("Something went wrong.");
break;
}
});
}, [stripe]);
const handleSubmit = async (e) => {
e.preventDefault();
if (!stripe || !elements) {
// Stripe.js has not yet loaded.
// Make sure to disable form submission until Stripe.js has loaded.
return;
}
setIsLoading(true);
const { error } = await stripe.confirmPayment({
elements,
confirmParams: { return_url: "http://localhost:3000/checkout" },
});
// This point will only be reached if there is an immediate error when
// confirming the payment. Otherwise, your customer will be redirected to
// your `return_url`. For some payment methods like iDEAL, your customer will
// be redirected to an intermediate site first to authorize the payment, then
// redirected to the `return_url`.
if (error.type === "card_error" || error.type === "validation_error") {
setMessage(error.message);
} else {
setMessage("An unexpected error occurred.");
}
setIsLoading(false);
};
return (
<form id="payment-form" onSubmit={handleSubmit}>
<PaymentElement id="payment-element" />
<button disabled={isLoading || !stripe || !elements} id="submit">
<span id="button-text">{isLoading ? <div className="spinner" id="spinner"></div> : "Pay now"}</span>
</button>
{/* Show any error or success messages */}
{message && <div id="payment-message">{message}</div>}
</form>
);
}
Any help in getting rid of this next.js mismatch error is greatly appreciated as I've been stuck on this issue for days! If you also have any advice/best practices on handling the return url flow, that would help tremendously since the stripe docs don't give much detail.
Thanks!