How to link a PayPal button to a Webflow Cloud checkout

How to link a PayPal button to a server-side checkout page on Webflow Cloud

Learn how to build a Webflow Cloud checkout page that creates and captures a PayPal order server-side.

How to link a PayPal button to a server-side checkout page on Webflow Cloud

Ismail Ajagbe
Technical Author
View author profile
Ismail Ajagbe
Technical Author
View author profile
Table of contents

A Designer button that links to a small checkout app on Webflow Cloud lets the server set the price and capture the PayPal payment on your own domain.

PayPal's button generator gives you a snippet that carries the price in your page's HTML and completes the payment on PayPal's side. That works for one fixed-price item. Once the server needs to set the price and confirm the payment on your own domain, the button needs a page with a server behind it.

Webflow Cloud gives you that page without leaving the site. In this guide, we build a small Next.js app mounted at /checkout that sets the price on the server and captures the payment when the buyer returns from PayPal.

What do you need to link a PayPal button to a checkout page in Webflow?

You need five prerequisites before you begin:

  • Webflow site: An existing site with a published marketing page that will hold the Pay button; Premium or higher only matters for a custom-domain mount.
  • PayPal REST credentials: A PayPal REST app's client ID and client secret from the PayPal developer dashboard, in sandbox and live versions.
  • Node.js and npm: Both installed locally. Webflow Cloud supports only npm, which rules out pnpm and yarn commands on this platform.
  • Next.js 15+ project: A Next.js 15 or higher project in a repository you can connect to Webflow Cloud; create-next-app generates one.
  • Sandbox buyer account: A test buyer account in PayPal's sandbox so that you can complete a payment end to end without a real card.

The PayPal calls live in the server code, and the Designer button links to the finished app.

6 steps to link a PayPal button to a checkout page in Webflow Cloud

The six-step build uses a Route Handler to set the amount beyond browser edits, creates and captures the PayPal order server-side, and links the Designer button to the app mounted at /checkout.

It runs in a Next.js app mounted on Webflow Cloud at /checkout, and the Designer change is a link to that path.

1. Scaffold the Next.js app and set the PayPal variables locally

Create a fresh Next.js project with npm and the App Router, then give it the PayPal values it needs through environment variables to keep credentials out of source. I keep the checkout app in its own repository so the build has nothing else to install.

Run the scaffold from the directory where you want the project:

npx create-next-app@15 checkout
cd checkout

The @15 tag pins the scaffold to the supported Next.js 15 major; this build works on Next.js 15 or higher.

Accept TypeScript, the App Router, and the default @/* import alias when prompted, and decline the src/ directory; later files import from @/lib/paypal and rely on that alias. Leave next.config.ts alone.

Webflow Cloud's configuration docs state, "You don't need to add an adapter, a base path, or an output mode," because Webflow Cloud injects the base path at build time.

Configuring environment variables for local development

Six variables go in a .env.local file at the project root, with sandbox values for now:

Variable Value for local development Secret?
PAYPAL_API_BASE https://api-m.sandbox.paypal.com No
PAYPAL_CLIENT_ID Your sandbox app's client ID No
PAYPAL_CLIENT_SECRET Your sandbox app's client secret Yes
PAYPAL_RETURN_URL http://localhost:3000/return No
PAYPAL_CANCEL_URL http://localhost:3000/cancel No
NEXT_PUBLIC_BASE_PATH Leave empty No
Variable → Value for local development → Secret?
PAYPAL_API_BASE
https://api-m.sandbox.paypal.com
No
PAYPAL_CLIENT_ID
Your sandbox app's client ID
No
PAYPAL_CLIENT_SECRET
Your sandbox app's client secret
Yes
PAYPAL_RETURN_URL
http://localhost:3000/return
No
PAYPAL_CANCEL_URL
http://localhost:3000/cancel
No
NEXT_PUBLIC_BASE_PATH
Leave empty
No

NEXT_PUBLIC_BASE_PATH stays empty locally because next dev serves the app at the root; on Webflow Cloud it becomes /checkout. Running npm run dev now serves the starter page at localhost:3000.

2. Write the PayPal client and the order Route Handler

I put every PayPal call in one server module, then expose a single POST route that creates an order and returns its approval link. The module fetches an access token with the client ID and secret, creates an order, reads an order's status, and captures one; the Route Handler calls only the first two and sets the amount itself.

The server module is lib/paypal.ts:

type PayPalConfig = { base: string; clientId: string; clientSecret: string };

function config(): PayPalConfig {
  const base = process.env.PAYPAL_API_BASE;
  const clientId = process.env.PAYPAL_CLIENT_ID;
  const clientSecret = process.env.PAYPAL_CLIENT_SECRET;
  if (!base || !clientId || !clientSecret) {
    throw new Error("PayPal environment variables are missing");
  }
  return { base, clientId, clientSecret };
}

export async function getAccessToken(): Promise<string> {
  const { base, clientId, clientSecret } = config();
  const credentials = btoa(`${clientId}:${clientSecret}`);
  const res = await fetch(`${base}/v1/oauth2/token`, {
    method: "POST",
    headers: {
      Authorization: `Basic ${credentials}`,
      "Content-Type": "application/x-www-form-urlencoded",
    },
    body: "grant_type=client_credentials",
  });
  if (!res.ok) {
    throw new Error(`PayPal token request failed: ${res.status}`);
  }
  const data = (await res.json()) as { access_token: string };
  return data.access_token;
}

export async function createOrder(amount: string, currency: string) {
  const { base } = config();
  const token = await getAccessToken();
  const res = await fetch(`${base}/v2/checkout/orders`, {
    method: "POST",
    headers: {
      Authorization: `Bearer ${token}`,
      "Content-Type": "application/json",
    },
    body: JSON.stringify({
      intent: "CAPTURE",
      purchase_units: [{ amount: { currency_code: currency, value: amount } }],
      payment_source: {
        paypal: {
          experience_context: {
            return_url: process.env.PAYPAL_RETURN_URL,
            cancel_url: process.env.PAYPAL_CANCEL_URL,
            user_action: "PAY_NOW",
          },
        },
      },
    }),
  });
  if (!res.ok) {
    throw new Error(`PayPal order creation failed: ${res.status}`);
  }
  return (await res.json()) as {
    id: string;
    links: { rel: string; href: string }[];
  };
}

export type PayPalOrder = {
  id: string;
  status: string;
  purchase_units?: { payments?: { captures?: { id: string; status: string }[] } }[];
};

export async function captureOrder(orderId: string) {
  const { base } = config();
  const token = await getAccessToken();
  const res = await fetch(`${base}/v2/checkout/orders/${orderId}/capture`, {
    method: "POST",
    headers: {
      Authorization: `Bearer ${token}`,
      "Content-Type": "application/json",
      Prefer: "return=representation",
      "PayPal-Request-Id": `capture-${orderId}`,
    },
  });
  if (!res.ok) {
    throw new Error(`PayPal capture failed: ${res.status}`);
  }
  return (await res.json()) as PayPalOrder;
}

export async function getOrder(orderId: string) {
  const { base } = config();
  const token = await getAccessToken();
  const res = await fetch(`${base}/v2/checkout/orders/${orderId}`, {
    method: "GET",
    headers: {
      Authorization: `Bearer ${token}`,
      Prefer: "return=representation",
    },
  });
  if (!res.ok) {
    throw new Error(`PayPal order lookup failed: ${res.status}`);
  }
  return (await res.json()) as PayPalOrder;
}

I build the Basic credential with btoa rather than a Node-specific helper. The env vars are read inside config() when the function runs, so a missing value produces the readable error defined there instead of failing at import.

The order body sends the intent and the amount, plus the return and cancel URLs inside payment_source.paypal.experience_context, which replaces the deprecated application_context in Orders v2.

Handling PayPal order approval and capture idempotency

With that payment source set, PayPal labels the approval link payer-action, so the route accepts either label. The capture call sends a PayPal-Request-Id header, which PayPal uses for idempotency, so a repeated capture of the same order returns the first result instead of failing.

Both lookups ask for return=representation so the response includes the capture details.

Add the Route Handler at app/api/paypal/order/route.ts:

import { NextResponse } from "next/server";
import { createOrder } from "@/lib/paypal";

const PRICE = "20.00";
const CURRENCY = "USD";

export async function POST() {
  try {
    const order = await createOrder(PRICE, CURRENCY);
    const approve = order.links.find(
      (link) => link.rel === "payer-action" || link.rel === "approve"
    );
    if (!approve) {
      return NextResponse.json(
        { error: "PayPal returned no approval link" },
        { status: 502 }
      );
    }
    return NextResponse.json({ orderId: order.id, approveUrl: approve.href });
  } catch (error) {
    console.error(error);
    return NextResponse.json({ error: "Could not create order" }, { status: 500 });
  }
}

PRICE and CURRENCY are server constants on purpose; anything the browser sends can be edited, so the amount never comes from the request body. Do not add export const runtime = 'edge' to this file; the OpenNext Cloudflare adapter Webflow Cloud builds with does not support that target.

Can someone outside your site call the order route directly?

Yes. POST /checkout/api/paypal/order is public, so that anyone can create $20.00 orders in a loop; the price is fixed, but the volume isn't. The return page captures whatever order token it receives.

Before going live, check the Origin header and rate-limit the order route, and tie each order ID to a server-side checkout session before capturing it. PAYPAL_CLIENT_SECRET never leaves the server.

With npm run dev running, a POST to localhost:3000/api/paypal/order now returns JSON containing orderId and a PayPal approveUrl.

3. Build the checkout page and the PayPal button

Render the checkout page as a server component and keep the button in a small client component that calls your Route Handler and then sends the buyer to the approval URL.

A production fetch must include the base path. The bring-your-own-app page in Webflow Cloud's docs is explicit that "Client-side fetch calls must manually include the base path." Because the button holds browser state, it is a client component.

Save it as app/components/PayPalButton.tsx:

"use client";

import { useState } from "react";

const basePath = process.env.NEXT_PUBLIC_BASE_PATH ?? "";

export default function PayPalButton() {
  const [pending, setPending] = useState(false);
  const [error, setError] = useState<string | null>(null);

  async function startCheckout() {
    setPending(true);
    setError(null);
    try {
      const res = await fetch(`${basePath}/api/paypal/order`, { method: "POST" });
      if (!res.ok) {
        setError("Checkout could not start. Please try again.");
        return;
      }
      const data = (await res.json()) as { approveUrl: string };
      window.location.assign(data.approveUrl);
    } catch {
      setError("Checkout could not start. Please try again.");
    } finally {
      setPending(false);
    }
  }

  return (
    <div>
      <button onClick={startCheckout} disabled={pending}>
        {pending ? "Redirecting to PayPal..." : "Pay with PayPal"}
      </button>
      {error && <p role="alert">{error}</p>}
    </div>
  );
}

Prefixing NEXT_PUBLIC_BASE_PATH makes the same code hit /api/paypal/order locally and /checkout/api/paypal/order in production.

Creating the main checkout page component

Replace the generated app/page.tsx with the checkout page itself:

import PayPalButton from "./components/PayPalButton";

export default function CheckoutPage() {
  return (
    <main>
      <h1>Checkout</h1>
      <p>Workshop seat: $20.00 USD</p>
      <PayPalButton />
    </main>
  );
}

The displayed price is text. The Route Handler independently decides what value to send in the order request. Clicking Pay with PayPal at localhost:3000 creates the order and redirects the browser to the approveUrl returned by PayPal.

4. Capture the payment on return

When PayPal sends the buyer back, the server looks up the order and calls captureOrder unless the returned status is already COMPLETED, all before rendering anything. The return page reads the token query parameter, and the server component calls getOrder and captureOrder directly. Base-path handling applies only to browser fetches.

The return page, app/return/page.tsx, does the check and the capture on the server:

import { captureOrder, getOrder } from "@/lib/paypal";

export default async function ReturnPage({
  searchParams,
}: {
  searchParams: Promise<{ token?: string }>;
}) {
  const { token } = await searchParams;
  if (!token) {
    return (
      <main>
        <h1>Missing order reference</h1>
      </main>
    );
  }
  try {
    const order = await getOrder(token);
    const result =
      order.status === "COMPLETED" ? order : await captureOrder(token);
    const capture = result.purchase_units?.[0]?.payments?.captures?.[0];
    const paid = capture?.status === "COMPLETED";
    return (
      <main>
        <h1>{paid ? "Payment received" : "Payment pending"}</h1>
        <p>PayPal order {result.id}</p>
      </main>
    );
  } catch (error) {
    console.error(error);
    return (
      <main>
        <h1>We could not confirm your payment</h1>
      </main>
    );
  }
}

In this implementation, searchParams is a Promise, hence the await.

Payment received depends on the capture's own status, not the order's, because a capture can be PENDING while PayPal reviews it; that buyer sees Payment pending instead.

A buyer who reloads /checkout/return after paying sees Payment received again, and the PayPal-Request-Id header keeps two simultaneous loads from capturing twice.

Creating the checkout cancellation page

Add a short cancel page at app/cancel/page.tsx for the cancel URL:

export default function CancelPage() {
  return (
    <main>
      <h1>Payment cancelled</h1>
    </main>
  );
}

Approving a sandbox payment and returning now shows Payment received with the PayPal order ID on screen.

5. Mount the app on Webflow Cloud and add the same variables

Push the repository so Webflow Cloud can build from it. In the site's Webflow Cloud settings, create a project, connect it to the repository, and set the environment's mount path to /checkout before the first deploy.

The mount path has to be set first because Webflow Cloud injects the base path at build time, and NEXT_PUBLIC_BASE_PATH has to match it. Choose an unused mount path to prevent routing conflicts with an existing Webflow page.

If the site already has a page at /checkout, mount at something like /pay and use that value for NEXT_PUBLIC_BASE_PATH and the Designer link.

Configuring Webflow Cloud deployment variables

Add the six variables to the environment with deployment values:

  • PAYPAL_API_BASE: Keep the sandbox base URL for the first deploy; swap to the live base, https://api-m.paypal.com, only after a sandbox payment completes end to end.
  • PAYPAL_CLIENT_ID and PAYPAL_CLIENT_SECRET: Enter the pair that matches the base URL, and mark the secret as a secret so Webflow Cloud redacts it from build logs.
  • PAYPAL_RETURN_URL and PAYPAL_CANCEL_URL: Use absolute URLs on the deployed domain, for example https://your-site.webflow.io/checkout/return and https://your-site.webflow.io/checkout/cancel.
  • NEXT_PUBLIC_BASE_PATH: Set it to /checkout, matching the mount path exactly, so the client fetch resolves to the Route Handler in production.

Webflow Cloud's environment variables page states that "Both secret and non-secret environment variables are available to your application's build process and to the deployed application at runtime," which is why a NEXT_PUBLIC_ value, inlined at build, and a secret read at request time can share one panel.

With all six saved, deploy the environment. Once the build finishes, https://your-site.webflow.io/checkout renders the checkout page, and the button sends a sandbox buyer to PayPal and back to /checkout/return with Payment received.

6. Link the Designer button to /checkout

Point an ordinary button or link block on the marketing page at /checkout and publish. Select the element in the Designer, open its link settings, choose a URL link, and enter /checkout; a relative path should resolve against whichever domain served the page, so the same link works on webflow.io and on a custom domain.

Style the button however the brand calls for; the buyer approves the payment on PayPal's own page, so the on-site button only has to be clear about what happens next. Label the button with the price and the action, so the number on the button matches the number on the checkout page.

Publish the site. The published page now carries a button that opens /checkout on the same domain, and a sandbox buyer can go from that button to a completed PayPal payment and back. The buyer leaves your domain only for PayPal's approval page.

What causes a PayPal checkout page on Webflow Cloud to fail?

Failures usually come from four places: an unsupported Route Handler runtime target, a missing client-side base path, mismatched PayPal credentials and API base URLs, or a site plan that cannot mount the app on a custom domain.

Start with the build output for deployment failures; for runtime failures, inspect the browser and the app's logs.

A Webflow Cloud build fails on a Route Handler that works under next dev

Cause: The Route Handler contains export const runtime = 'edge', and Webflow Cloud deploys Next.js through the OpenNext Cloudflare adapter, which does not support that Next.js edge runtime target.

The docs use the word "edge" in two senses on the same page. Webflow Cloud runs on Cloudflare Workers, an edge platform, and the bring-your-own-app page describes deploying on the Edge runtime for globally distributed hosting; the same page still tells you to add the directive to API routes.

A developer who follows that instruction reads the platform sentence as a runtime requirement and produces a build that doesn't ship.

Fix: Let every Route Handler and page build through the same adapter as the rest of the app by deleting the export.

Middleware is the one place Webflow Cloud's docs say Edge runtime code works: middleware.ts runs on the Workers runtime, and the framework-customization page states that "Node.js runtime middleware isn't supported. Only Edge runtime middleware works on the Workers runtime."

If you scaffolded with create-next-app@latest instead and got Next 16, keep the file named middleware.ts rather than renaming it to proxy.ts, because proxy runs on the Node runtime and cannot opt into Edge, so the renamed file cannot run on Webflow Cloud.

The Pay with PayPal button returns a 404 for /api/paypal/order on the live site but works under next dev

Cause: The base path is injected at build time, which covers server-side routing, but the browser fetch isn't rewritten. Locally, the app sits at the root, so /api/paypal/order resolves.

On the live site, the app is mounted at /checkout, and a fetch to /api/paypal/order asks the Webflow site itself for a route that only exists under the mount path. The request typically comes back as a 404 instead of the order JSON.

Fix: Open the browser's network panel and read the request URL; if it lacks /checkout, NEXT_PUBLIC_BASE_PATH is either unset in the Cloud environment or set to a value that does not match the mount path.

Set it to /checkout exactly, with the leading slash and no trailing one, and deploy again so the client bundle is rebuilt with the new NEXT_PUBLIC_BASE_PATH value.

Do not try to fix this by adding basePath to next.config.ts; the configuration docs say you don't need one, and the injected value already handles the server side.

Pay with PayPal shows 'Checkout could not start' on Webflow Cloud while the same code works locally

Cause: PayPal's token endpoint rejects the request when the credentials do not match the base URL they are paired with. The Route Handler catches that error, so the browser sees a 500, and the token request's failure status appears only in the app's logs.

The config() function throws its own "environment variables are missing" error when a value is absent, so a token rejection means PayPal rejected a client ID and secret that did exist; one possible mismatch is sandbox credentials against the live API base, or live credentials against the sandbox base, after one of the three was changed in the Webflow Cloud environment and the other two were not. Webflow Cloud redacts secrets from build logs, so the log will not show you the stored value.

Fix: To restore checkout, re-enter PAYPAL_API_BASE, PAYPAL_CLIENT_ID, and PAYPAL_CLIENT_SECRET together, using the matching sandbox or live set from the PayPal developer dashboard, then save and redeploy. Log res. status from getAccessToken() while diagnosing; never the credential or the token.

/checkout is not reachable on the custom domain while it works on webflow.io

Cause: Mounting a Webflow Cloud app to a custom domain requires a Premium site plan or higher, while Webflow Cloud itself is included from the free Starter plan up. On a lower plan, the app cannot be mounted to the custom domain.

Fix: Move the site to Premium or higher and mount the environment on the custom domain. Until the plan changes, test the full flow on webflow.io, with PAYPAL_RETURN_URL and PAYPAL_CANCEL_URL set to that domain.

When the custom domain goes live, update both URLs to it and deploy; otherwise PayPal sends buyers back to the staging address after they pay. The Designer link stays /checkout throughout, since a relative path follows whichever domain the page was served from.

What you can build next with PayPal and Webflow

To let marketers set the price, replace the PRICE constant in the Route Handler with a server-side lookup against a CMS collection through Webflow's CMS API. You can then change the price in the CMS instead of in code.

I'd also add a PayPal webhook listener that verifies PayPal's signature and captures the order on CHECKOUT.ORDER.APPROVED, so a buyer who approves and then closes the tab is still charged and recorded. If you only need a fixed-price button and no server, PayPal's Webflow integration is the quicker route.

If you would rather take cards through Stripe without running a server, Stripe Checkout covers that route. For the price lookup, Webflow's developer docs cover the CMS APIs you would call.

Frequently asked questions

What do your buyers see if they cancel on PayPal?

PayPal sends your buyers to PAYPAL_CANCEL_URL, which is /checkout/cancel on your deployed site, and they see the Payment canceled page. If they change their mind, clicking your Designer button again sends a fresh POST to your Route Handler, which creates a brand-new PayPal order and starts approval from the beginning.

Can your checkout page sell more than one product or currency?

This implementation can sell multiple products or currencies if the browser sends only a product ID. Your Route Handler maps that ID to a price and currency in a server-side table and passes both to createOrder, whose currency argument fills currency_code. Pull your page's display text from the same table, so the price buyers read matches what PayPal charges.

What happens if your buyer approves the payment but closes the tab before returning?

Nothing is charged. Only app/return/page.tsx calls captureOrder, so an approved order that never reaches that page stays uncaptured, and PayPal lets it lapse after its approval window. The webhook listener described above closes that gap by capturing on approval instead.


Last Updated
October 9, 2026
Category

Related articles


verifone logomonday.com logospotify logoted logogreenhouse logoclear logocheckout.com logosoundcloud logoreddit logothe new york times logoideo logoupwork logodiscord logo
verifone logomonday.com logospotify logoted logogreenhouse logoclear logocheckout.com logosoundcloud logoreddit logothe new york times logoideo logoupwork logodiscord logo

Get started for free

Try Webflow for as long as you like with our free Starter plan. Purchase a paid Site plan to publish, host, and unlock additional features.

Get started — it’s free
Watch demo

Try Webflow for as long as you like with our free Starter plan. Purchase a paid Site plan to publish, host, and unlock additional features.