Stripe redirectToCheckout removed: how to fix it

Stripe removed stripe.redirectToCheckout from Stripe.js in the clover release, dated 2025-09-30. On @stripe/stripe-js 8 or later, the call throws instead of redirecting. If you have a server, create the Checkout Session there and send the browser to session.url. If you have no server, Stripe's replacement is a Payment Link or Buy Button. On Flint, a payment link is also the no-backend path, and every checkout through it creates an order.
Verified against official documentationReviewed Send a correction (opens in a new tab)

Stripe's changelog states it in one line: "The stripe.redirectToCheckout method is no longer supported in Stripe.js." The removal covers "both client-only and client and server" integrations, which is why it reaches two very different groups. Server-backed apps that passed a sessionId need a two-line change. Static sites and apps with no backend, which passed line items straight from the browser, lost the ability to build a Checkout cart in browser code.

Which versions are affected

Stripe.js now ships named versions twice a year, and each major version of @stripe/stripe-js loads one of them. The removal follows the Stripe.js version your page loads, not your account's API version:

How your page loads Stripe.jsStripe.js versionredirectToCheckout
js.stripe.com/v3, or @stripe/stripe-js below 6v3Stripe.js does not block the call
@stripe/stripe-js 6, or js.stripe.com/acaciaacaciaStripe.js does not block the call
@stripe/stripe-js 7, or js.stripe.com/basilbasilStripe.js does not block the call
@stripe/stripe-js 8, or js.stripe.com/clovercloverThrows an IntegrationError; the TypeScript types no longer declare the method
@stripe/stripe-js 9, or js.stripe.com/dahliadahliaThrows an IntegrationError
@stripe/stripe-js 10 (the current npm latest), or js.stripe.com/endiveendiveThrows an IntegrationError

A plain npm install @stripe/stripe-js installs version 10 today, so a new project, a fresh lockfile, or generated code that follows a tutorial written before October 2025 lands on a version where the call throws.

The four errors, and what each means

Only the first is the removal itself. The third, is not a function, has a different cause, and upgrading Stripe.js will not fix it.

  1. IntegrationError: stripe.redirectToCheckout is no longer supported in this version of Stripe.js#

    The full message continues: "See the changelog for more details: https://docs.stripe.com/changelog/clover/2025-09-30/remove-redirect-to-checkout." The click does nothing and the console shows the error.

    Your page loads Stripe.js clover or later. The method still exists on the Stripe object so it can throw this message, but it no longer redirects anywhere, with a session ID or with line items.

    Confirm it

    Run npm ls @stripe/stripe-js. Version 8 or higher loads clover or later. With a script tag, the version name is in the URL path.

    Fix

    Have your server return the Checkout Session's url and assign it to window.location, or redirect from the server with a 303. With no server, switch to a Payment Link.

  2. Property 'redirectToCheckout' does not exist on type 'Stripe'#

    TypeScript fails the build after you upgrade @stripe/stripe-js, before any code runs.

    @stripe/stripe-js 8.0.0 removed the method from its type definitions, matching the runtime removal in clover.

    Confirm it

    The error names the Stripe interface and appears only on @stripe/stripe-js 8 or higher.

    Fix

    Do not cast the error away: the call would throw at runtime. Replace the call with a redirect to session.url.

  3. TypeError: stripe.redirectToCheckout is not a function#

    The call fails on the first click, whichever Stripe.js version you think you load.

    Every version of Stripe.js defines the method, including the ones where it throws, so this error means stripe is not a Stripe.js instance. The common cause is the server library: stripe from the stripe-node package used in browser code, which is how a Stripe employee diagnosed this error on the stripe-node issue tracker. The other is a missing await, because loadStripe returns a Promise and a Promise has no such method.

    Confirm it

    Log stripe just before the call. A Stripe.js instance comes from loadStripe("pk_...") or Stripe("pk_..."); the server library is created with a secret key and must never ship to a browser.

    Fix

    If a secret key is in browser code, remove it and rotate it in the Dashboard. Then use the server pattern below: the secret key stays on the server, and the browser only follows a URL.

  4. Cannot read properties of null (reading 'redirectToCheckout')#

    The call fails during server rendering, or in a test runner, but not in the browser.

    loadStripe resolves to null in a server environment, by design.

    Confirm it

    The stack trace runs through server-side rendering or a Node test.

    Fix

    Only run checkout code in the browser after a click. Once the redirect goes through session.url, the click handler no longer needs a Stripe object at all.

The fix with a server: redirect to session.url

This is the code most integrations have:

JavaScript
// Worked before clover. Throws on @stripe/stripe-js 8 and later.
const stripe = await loadStripe("pk_test_...");
const { id } = await fetch("/create-checkout-session", { method: "POST" })
  .then((r) => r.json());
await stripe.redirectToCheckout({ sessionId: id });

The server already creates the session. Change what it returns, from the ID to the URL, as Stripe's changelog directs: "use standard redirect functions (such as window.location.href = session.url)".

JavaScript
// server.js (Express). The secret key never leaves the server.
const stripe = require("stripe")(process.env.STRIPE_SECRET_KEY);

app.post("/create-checkout-session", async (req, res) => {
  const session = await stripe.checkout.sessions.create({
    mode: "payment",
    line_items: [{ price: "price_...", quantity: 1 }],
    success_url: "https://example.com/thanks?session_id={CHECKOUT_SESSION_ID}",
    cancel_url: "https://example.com/cart",
  });
  // Return the URL, not just the ID.
  res.json({ url: session.url });
});
JavaScript
// Browser. No Stripe object needed for the redirect itself.
const { url } = await fetch("/create-checkout-session", { method: "POST" })
  .then((r) => r.json());
window.location.assign(url);

Or skip client JavaScript with a 303

Stripe's own Checkout quickstart posts a plain form and redirects from the server, so the button works with JavaScript disabled:

HTML
<!-- No client JavaScript at all: the browser follows the 303. -->
<form action="/create-checkout-session" method="POST">
  <button type="submit">Checkout</button>
</form>
JavaScript
app.post("/create-checkout-session", async (req, res) => {
  const session = await stripe.checkout.sessions.create({ /* same as above */ });
  res.redirect(303, session.url);
});

What goes wrong in the migration

  • Calling the 303 endpoint with fetch. fetch follows the redirect itself and hands your code the Checkout page as a response; the browser tab never navigates, so the buyer sees nothing happen. Use the form with the 303, or fetch with the JSON response, not fetch with the 303.
  • Building the URL from the ID. Use url exactly as returned. Stripe's documented example carries more than the session ID, and with a custom domain the URL uses your subdomain instead of checkout.stripe.com.
  • A null url. url applies to hosted sessions, the default ui_mode, and "is only present when the session is active." Embedded sessions never have one, and a session that has completed or expired no longer does, so create the session when the buyer clicks rather than when the page loads.
  • Dropping Stripe.js from your pages. The redirect no longer needs it, but the stripe-js README recommends loading Stripe.js on every page so Stripe's fraud detection sees how buyers browse. Keep loading it; just stop calling the removed method.

Pinning an older Stripe.js

If you need the old call working while you migrate, pin a version that still has it:

Shell
# Stopgap only: keeps Stripe.js on basil, which does not block the call.
npm install @stripe/stripe-js@7

# Or, with a script tag:
# <script src="https://js.stripe.com/basil/stripe.js"></script>

Stripe's versioning policy says "We continue to support and update older versions," and that v3 stays supported "for the foreseeable future." No end date has been published. The costs: TypeScript types are not backported to old @stripe/stripe-js majors, new features arrive in newer versions, and Stripe recommends keeping Stripe.js and your server's API version on the same release train. A pin buys time; it is not a destination.

The fix with no backend: Payment Links

The client-only integration called redirectToCheckout with Price IDs, a mode, and a success URL from the browser, using only the publishable key. Stripe's instruction for it is to "migrate to Payment Links and Buy Buttons." Create the link in the Dashboard, then use it as a plain link:

HTML
<!-- A Payment Link from the Dashboard, used as a plain link.
     client_reference_id comes back on checkout.session.completed. -->
<a href="https://buy.stripe.com/test_...?client_reference_id=cart_1842">
  Buy now
</a>

Or click Buy button on the link in the Dashboard and paste the embed code it generates:

HTML
<script async src="https://js.stripe.com/v3/buy-button.js"></script>
<stripe-buy-button
  buy-button-id="buy_btn_..."
  publishable-key="pk_test_..."
></stripe-buy-button>

The Buy Button needs a website domain to render, so test it from a local HTTP server rather than a file opened from disk.

  • The cart is fixed when you create the link. A Payment Link carries up to 20 line items, optional items included, chosen when it is created. Buyers can adjust quantities you allow, but browser code can no longer assemble an arbitrary cart. Selling any combination of products means one link per offer, or a server.
  • Per-buyer data is a URL parameter. client_reference_id accepts up to 200 characters of letters, digits, dashes, and underscores, and comes back on checkout.session.completed. Invalid values are silently dropped. UTM codes are passed through to your redirect URL when the link redirects after payment. Do not put anything secret in a link you share.
  • Fulfillment still needs somewhere to run. With no server, payments appear in the Dashboard, and you can turn on an email for every successful payment. Automated fulfillment means receiving checkout.session.completed, which is a server again. The webhook events reference covers which event to trust.

In exchange, prices are set in the Dashboard, so code in your page cannot change what a buyer is charged beyond options you enable on the link, such as promotion codes, and the only key in your page is a publishable one.

Questions, answered

Why is stripe.redirectToCheckout not working anymore?

Stripe removed it from Stripe.js in the clover release, dated 2025-09-30. Any page that loads @stripe/stripe-js 8 or later, or a clover, dahlia, or endive script URL, gets an IntegrationError instead of a redirect. Redirect the browser to the Checkout Session's url instead.

What replaces redirectToCheckout?

For integrations with a server, create the Checkout Session on the server and send the browser to session.url, either with window.location or a 303 redirect from the server. For client-only integrations with no server, Stripe's replacement is a Payment Link, optionally embedded as a Buy Button.

Can I keep using redirectToCheckout by pinning an older Stripe.js?

For now. Stripe.js v3, acacia, and basil do not block the call; load them through @stripe/stripe-js 7 or lower, or a script URL with that version name. Stripe says it continues to support older versions and has published no end date, but TypeScript types are not backported and new features land in newer versions. Treat a pin as time to migrate.

Can I use Stripe Checkout without a backend?

Through Stripe's no-code embeds: Payment Links, Buy Buttons, and, for subscriptions, the embeddable pricing table, which all take only a publishable key or none. Creating a Checkout Session yourself takes a secret key, which must stay on a server. A Payment Link is created once in the Dashboard and works as a plain URL. Its items, up to 20, are set when you create it, not by code in the browser.

Why does fetch not follow my server's redirect to Stripe Checkout?

A redirect answered to fetch is followed by fetch itself, not by the page: your code receives the Checkout page as a response and the browser tab never navigates. Either submit a plain HTML form to an endpoint that responds with a 303, or have the endpoint return the URL as JSON and assign it to window.location.

Is it safe to build the Checkout URL from the session ID?

No. Use the url field exactly as returned. It is only present while the session is open, it uses your subdomain if you have configured a custom domain, and Stripe's documented URL carries more than the ID. Older code that passed only the ID needs its server endpoint changed to return the url.

Can a browser create a Flint checkout with no API key?

It can open a Flint payment link, which needs no key and no server. Flint's public resolve endpoint also takes no key, but Flint's API does not answer cross-origin browser requests from your own domain, so code on your site cannot call it. For a cart built in your app, create the order and checkout session in a server function.

On Flint, a link that creates an order

Flint is a payments API with orders, tax, and inventory built in, running on Stripe rails. Its no-backend path is the same shape as Stripe's: a payment link, created once, opened as a URL. What differs is what each checkout leaves behind. Every checkout through a Flint link creates its own order, with the link's line items, tax when the link enables it, origin: "payment_link", and the link's metadata copied on, so refunds and reporting work per order instead of per payment. Create the link in the dashboard, or with one request:

cURL
curl -X POST https://api.withflintpay.com/v1/payment-links \
  -H "Content-Type: application/json" \
  -H "Authorization: Bearer YOUR_API_KEY" \
  -H "Idempotency-Key: payment-link-field-notes-001" \
  -d '{
    "name": "Field notes, 3-pack",
    "line_items": [{
      "name": "Field notes, 3-pack",
      "quantity": 1,
      "unit_price_money": { "amount": 1800, "currency": "USD" },
      "allow_quantity_adjustment": true,
      "min_quantity": 1,
      "max_quantity": 5
    }],
    "tax": { "enabled": true },
    "customer_collection": { "require_email": true },
    "redirects": { "success_redirect_url": "https://example.com/thanks" },
    "inactive_message": "This edition is sold out."
  }'
JSON
{
  "data": {
    "payment_link_id": "pl_1kmn0aExample",
    "url": "https://checkout.withflintpay.com/pay/pl_1kmn0aExample",
    "status": "active"
  }
}

Your page needs only the URL. There is no publishable key to embed, because Flint has none: every Flint key is a server credential, and a payment link needs no key at all.

HTML
<a href="https://checkout.withflintpay.com/pay/pl_1kmn0aExample">Buy now</a>

Buyers choose a quantity between the bounds you set, max_completions caps a limited drop, and inactive_message is what buyers see after you deactivate the link. Fulfill from the order.paid webhook when you add a server, or from the dashboard until then.

When the app needs a cart

A cart assembled in your app needs a server function, as it does on Stripe. On Flint it is two calls, and Flint prices the order, so the function sends items and gets back a URL:

JavaScript
// One server function, any runtime with fetch. The key comes from
// the runtime's secret store and holds two scopes:
// commerce.orders.write and checkouts.checkout_sessions.write.
async function startCheckout(cart) {
  const headers = {
    Authorization: `Bearer ${process.env.FLINT_API_KEY}`,
    "Content-Type": "application/json",
  };

  const order = await fetch("https://api.withflintpay.com/v1/orders", {
    method: "POST",
    headers: { ...headers, "Idempotency-Key": `order-${cart.id}` },
    body: JSON.stringify({
      line_items: cart.items.map((item) => ({
        variant_id: item.variantId,
        quantity: item.quantity,
      })),
    }),
  }).then((r) => r.json());

  const checkout = await fetch("https://api.withflintpay.com/v1/checkout-sessions", {
    method: "POST",
    headers: { ...headers, "Idempotency-Key": `checkout-${cart.id}` },
    body: JSON.stringify({
      order_id: order.data.order_id,
      redirects: { success_redirect_url: "https://example.com/thanks" },
    }),
  }).then((r) => r.json());

  // The browser assigns this to window.location, exactly as returned.
  return checkout.data.checkout_session.url;
}

The browser then does what it does on Stripe after the migration: window.location.assign(url). Flint computes the order's totals, so no amount travels from the browser. Flint also has keyless endpoints that start a payment link checkout, but they serve Flint's own hosted pages: Flint's API does not answer cross-origin browser requests from your site, so call Flint from the server function or link to the payment link.

The payment links guide covers link types, custom fields, and fulfillment, and checkout sessions covers the server path. For apps built with Lovable, v0, Bolt, or Replit, payments for AI app builders shows where each one keeps server code and secrets.

Sources