DEV Community

Daniel Pertu
Daniel Pertu

Posted on

A purchase intent has to survive a round trip through Google, so ours rides in a query parameter that deletes itself on arrival

On Munchable the pricing table sits on the homepage, which means the person clicking "Get Premium" is usually signed out. Checkout needs an account. Sign in is Google OAuth, which is a full page navigation to another origin and a redirect back.

So between the click and the Stripe page there is a trip through a domain we do not control, and the user's intent has to be waiting for them when they land. If it is not, the button's real behaviour is "take me to a sign-in form and abandon me there", which is the most expensive bug a pricing page can have.

Here is the whole mechanism, which turned out to be about twenty lines and one unglamorous browser API.

The intent is a query parameter

The signed-out branch of the call to action does not try to be clever:

const { error } = await signIn('google', '/?checkout=1');
Enter fullscreen mode Exit fullscreen mode

That return path is handed to the provider, comes back on our own callback route, and is validated there before anybody is redirected anywhere:

// GET /auth/callback?code=...&next=/...
const next = safeReturnPath(searchParams.get('next'), '/');

if (code) {
  const supabase = await createClient();
  const { error } = await supabase.auth.exchangeCodeForSession(code);
  if (!error) return NextResponse.redirect(new URL(next, origin));
}

return NextResponse.redirect(new URL('/?auth_error=1', origin));
Enter fullscreen mode Exit fullscreen mode

safeReturnPath accepts a same-origin relative path and nothing else, because a next parameter is an open redirect waiting to happen. Accounts are implicit here: a first OAuth sign in creates one, so there is no signup page to route through and the only question is where the visitor was trying to get to.

A query parameter rather than storage, deliberately. The intent has to survive a navigation that leaves our origin entirely and comes back through a redirect chain, and the thing that already survives that is the URL, because it is the one piece of state the redirect contract carries. It is also visible: a support email can quote the link they were on, and I can reproduce the state by typing it.

The resume, and the bug inside it

export function CheckoutResume() {
  const { ready, user } = useAuth();
  const fired = useRef(false);

  useEffect(() => {
    if (!ready || !user || fired.current) return;
    const params = new URLSearchParams(window.location.search);
    if (params.get('checkout') !== '1') return;

    fired.current = true;
    params.delete('checkout');
    const qs = params.toString();
    window.history.replaceState(
      null,
      '',
      window.location.pathname + (qs ? `?${qs}` : ''),
    );
    void goToCheckout();
  }, [ready, user]);

  return null;
}
Enter fullscreen mode Exit fullscreen mode

Three decisions in there are each worth a sentence.

replaceState, not pushState, and before the redirect rather than after. The user is about to be sent to Stripe's hosted page. A fair number of them will look at the price and press back. If the history entry they come back to still says ?checkout=1, the effect runs again and they are shot straight back to Stripe. That is not an annoyance, it is a page the back button cannot escape. Rewriting the current entry in place means the thing they return to is a clean homepage.

And it has to happen before goToCheckout(), because that function sets window.location.href. Scheduling a history rewrite behind a navigation that may already be in flight is a race with no reason to exist.

A ref, not state. This effect depends on ready and user, and both of those change more than once in a normal load: the session is restored, then verified. In development, React also invokes effects twice on mount. A useState guard would not help, because setting state schedules a render and the second invocation can run before that render lands. fired.current = true is synchronous, which is the property the guard actually needs.

The parameter is read from window.location, not from useSearchParams. In this codebase the hook means a Suspense boundary around a page that is otherwise fine to prerender, and the sign-in page next door avoids it for exactly the same reason. Inside an effect, window.location is simply available, and this component renders nothing, so there is no server-rendered output for it to disagree with.

What the button does while it does not know

The CTA has four states, and the uninteresting one is the one that matters:

if (!ready || (user && loading && !entitlement)) {
  return (
    <button type="button" className="btn btn-gold" disabled>
      Get Premium
    </button>
  );
}
Enter fullscreen mode Exit fullscreen mode

Same label, same size, disabled. The alternative is a spinner or a shorter placeholder, both of which change the height of a card in the middle of a pricing table while the visitor's eye is on it.

Then: signed out gets the OAuth path above; signed in and not Premium gets checkout; and signed in and already Premium gets a "Manage billing" button that opens the billing portal. That last branch is not a nicety. A "Get Premium" button shown to an existing subscriber is a second subscription, and the person it happens to is a paying customer.

The checkout call itself returns a reason, not a boolean

export type CheckoutResult =
  | { ok: true; url: string }
  | { ok: false; reason: 'not-signed-in' | 'error'; message?: string };
Enter fullscreen mode Exit fullscreen mode

The Stripe secret key never reaches the browser. A same-origin POST to /api/create-checkout sends the session cookie automatically, the route identifies the user server-side and returns only the hosted session URL, and a missing session comes back as a 401 that the client maps to not-signed-in rather than folding into a generic failure. Those two cases need different interfaces: one is "sign in first", the other is "something broke, try again".

The other place we have to ask early

There is a second surface with the same shape. After a sign-up, the chooser asks how you want to use the app: continue in this browser, or get the Android app. If you pick the app and you are not Premium yet, it offers Premium first, and then sends you to the Play Store.

That ordering is not a growth trick, it is a platform rule. The native app does not sell the subscription; a browser may. So the only moment we are allowed to make the offer to somebody heading for the store is while they are still on the website. If they are already Premium, the upsell is skipped entirely and the button goes straight to the store.

Looking at it

The pricing table is on the homepage, under the feature sections: five scans a month free, Premium for unlimited, with the note that VAT is included so the figure shown is the figure charged. The terms are what the subscription actually is, and the about page is the short version of what the app does and does not claim to do.

If you want to watch the mechanism, click "Get Premium" while signed out and look at the address bar when you come back from Google. The ?checkout=1 is there for one paint, and then it is not.

Top comments (0)