Reputation: 1525
I'm working with NextJS for the first time, and am incredibly impressed with how fast the sites it builds are ... until I add Stripe Elements.
My pagespeed mobile score ranges from 93-99 before I add Stripe. Afterwards, it's around 50. :(
I tried, per this dev.to article, to import Stripe using the /pure
import path:
import { loadStripe } from '@stripe/stripe-js/pure';
I'm not sure what that does, because it still puts the link to the external Stripe script in the <head>
, and doesn't add any additional tags:
<script src="https://js.stripe.com/v3"></script>
Nevertheless, it does appear to do something, because the pagespeed improves slightly, to the 58-63 range — but that's still unacceptable.
This is all done with the Stripe React library, so it looks something like this in implementation:
import React, { useEffect, useState } from 'react';
import { Elements } from '@stripe/react-stripe-js';
import ElementForm from './ElementForm';
import getStripe from '../lib/get-stripejs';
const PurchaseSection = () => {
const [ stripePromise, setStripePromise ] = useState(null);
const [ clientSecret, setClientSecret ] = useState('');
useEffect(() => {
fetch('api/keys', {
method: 'GET',
headers: { 'Content-Type': 'application/json' },
})
.then((res) => res.json())
.then((data) => {
setStripePromise(getStripe(data.publishableKey));
});
}, []);
useEffect(() => {
fetch('api/create-payment-intent', {
method: 'POST',
header: { 'Content-Type': 'applcation/json' },
body: JSON.stringify({
productId: '34032255',
productType: 'seminar'
})
})
.then(async (res) => {
const { clientSecret } = await res.json();
setClientSecret(clientSecret);
});
}, [])
return (
<section>
{stripePromise && clientSecret && (
<Elements stripe={stripePromise} options={{ clientSecret }}>
<ElementForm />
</Elements>
)}
</section>
)
}
lib/get-stripejs
import { loadStripe } from '@stripe/stripe-js/pure';
let stripePromise;
const getStripe = (publishableKey) => {
if (!stripePromise) {
stripePromise = loadStripe(publishableKey);
}
return stripePromise;
}
export default getStripe
ElementForm
import React, { useState } from "react";
import { PaymentElement, useStripe, useElements } from '@stripe/react-stripe-js';
function ElementForm({ paymentIntent = null }) {
const stripe = useStripe();
const elements = useElements();
const [ isProcessing, setIsProcessing ] = useState(false);
async function handleSubmit(e) {
// do stuff
}
return (
<form id='payment-form' onSubmit={handleSubmit}>
<div id='payment-element'>
<PaymentElement />
</div>
<button disabled={isProcessing} type='submit'>
{isProcessing ? 'Processing...' : 'Submit'}
</button>
</form>
);
}
export default ElementForm
I'm not sure if the problem is the <PaymentElement>
or whatever loadStripe
does, or both, but my thought is I want to do something like next/script
offers, and at least play around with the various strategies. The problem is, that appears to only apply to items coming from a src
(which I guess this ultimately is, but that's not how it's done in the code, because of using the various Stripe packages).
So, a) is there some way for me to apply the next/script
strategies directly to components, rather than remote scripts?
Or, b) more broadly, what's the "nextjs" magic way to defer loading of all of this Stripe stuff? (NB: the <PurchaseSection>
component doesn't appear until well below the fold, so there's no reason for this to load early or in any sort of blocking way.)
Upvotes: 2
Views: 1233
Reputation: 1525
Based on @juliomalves comment, I went and played with next/dynamic
and IntersectionObserver
. Essentially, you have to make a user-experience tradeoff here.
Deferring loading of Elements
until it enters the viewport improves the PageSpeed metric to 80, which is better, but not great. The low score is primarily caused by a 5sec time to interactive.
If, in addition to that, I defer the loading of the Stripe library itself, the PageSpeed jumps back up to the mid-90s ... but when the user scrolls down to the point where they want to enter their payment info and buy, there will be that 5 second delay before the form shows up.
I'm honestly not sure which experience is worse, and which will cause more drop-offs, so you probably need to run your own experiments.
I put the IntersectionObserver
in a custom hook, since, since I'm down this path already, I'm sure I'll be using it in other places:
utils/showOnScreen.js
import React, { useEffect, useState} from 'react';
const showOnScreen = (ref) => {
const [ isIntersecting, setIsIntersecting ] = useState(false);
useEffect(() => {
const observer = new IntersectionObserver(
([entry]) => setIsIntersecting(entry.isIntersecting)
);
if (ref.current) {
observer.observe(ref.current);
}
}, []);
return isIntersecting;
}
export default showOnScreen
components/PurchaseSection.js
import React, { useEffect, useRef, useState } from 'react';
import dynamic from 'next/dynamic';
import { Elements } from '@stripe/react-stripe-js';
import getStripe from '../lib/get-stripejs';
import showOnScreen from '../utils/showOnScreen';
// We're making the `ElementForm` dynamic, but not `Elements`
// itself, as the latter is not a renderable component, but
// just a wrapper to pass state down to child components.
const ElementForm = dynamic(() => import('./ElementForm'));
const PurchaseSection = () => {
const purchaseSectionRef = useRef();
const purchaseSectionRefValue = showOnScreen(purchaseSectionRef);
// we're keeping track of this state to deal with `IntersectionObserver`
// transitions if the user scrolls (e.g., false, true, false, true).
// An alternative would be to remove the observer once it flips to
// true.
const [ isPurchaseSectionRef, setIsPurchaseSectionRef ] = useState(false);
useEffect(() => {
// if we've ever seen the section before, don't change anything,
// so we don't re-render
if (!isPurchaseSectionRef) {
setIsPurchaseSectionRef(purchaseSectionRefValue);
}, [purchaseSectionRefValue]);
...
return (
<section>
{ isPurchaseSectionRef && <div> { /* only true when we've intersected at least once */ }
{stripePromise && clientSecret && (
<Elements stripe={stripePromise} options={{ clientSecret }}>
<ElementForm />
</Elements>
)}
</div> }
</section>
)
}
If you also want to defer the loading of the library itself, so you get the faster page-load, but slower form-load when the user gets to the payment section, you need to pull the Stripe API calls into the new useEffect
, and defer loading of getStripe
:
import React, { useEffect, useRef, useState } from 'react';
import dynamic from 'next/dynamic';
import { Elements } from '@stripe/react-stripe-js';
// import getStripe from '../lib/get-stripejs';
import showOnScreen from '../utils/showOnScreen';
...
useEffect(() => {
// if we've ever seen the section before, don't change anything, so we don't rerender
if (!isPurchaseSectionRef) {
setIsPurchaseSectionRef(purchaseSectionRefValue);
// only do Stripe interactions if we've intersected
// this functionality used to be in `useEffect(() => {...}, [])`
if (purchaseSectionRefValue) {
fetch('api/keys', {
method: 'GET',
headers: { 'Content-Type': 'application/json' },
})
.then((res) => res.json())
.then(async (data) => {
const getStripe = (await import('../lib/get-stripejs')).default; // <- getStripe deferred until here
setStripePromise(getStripe(data.publishableKey));
})
fetch('api/create-payment-intent', {
method: 'POST',
header: { 'Content-Type': 'applcation/json' },
body: JSON.stringify({
productId: '34032255',
productType: 'seminar'
})
})
.then(async (res) => {
const { clientSecret } = await res.json();
setClientSecret(clientSecret);
})
}
}
}, [purchaseSectionRefValue]);
...
Upvotes: 2