A Checkout return route on Webflow Cloud turns the moment a payment clears into a gated dashboard session. That way, the public thank-you page is no longer the end of the purchase.
Most Stripe Checkout setups on a marketing site stop at the receipt. The customer pays, Stripe sends them to a success URL, and that URL is a static page anyone can bookmark and share.
The part that makes the purchase feel like a product (a dashboard that opens after payment and refuses visitors without a valid cookie) needs code that runs on a server: something has to confirm with Stripe that this session was paid for before it hands out access.
Webflow Cloud runs that server code. The build is a Next.js app mounted under your Webflow site that opens a dashboard only after Stripe confirms the charge. Your marketing pages stay in the Designer; the Buy button is a plain link into the app.
What do you need to gate a dashboard behind Stripe payment in Webflow?
You need a supported Webflow Cloud environment, current Next.js and Node.js setups, Stripe product credentials, and a strong cookie-signing secret. Webflow Cloud is available from the free Starter site plan up, and mounting an app to a custom domain requires Premium or higher.
Gather the platform, application, payment, and signing requirements before creating the Checkout routes.
Prepare the following prerequisites:
- Webflow Cloud environment: Use a supported site and environment. Webflow Cloud runs on Cloudflare Workers; Starter works, while custom-domain mounting requires Premium or higher.
- Next.js application: Use a Next.js 15 app with the App Router, and pin the major version. Next.js 16 deprecates
middleware.tsand renames it toproxy.ts; the Next.js docs say it "defaults to using the Node.js runtime" and that "the config option is not available in Proxy files," so it cannot opt into Edge. Webflow Cloud runs only Edge runtime middleware, so on 16 the dashboard gate in step 4 stops running. Astro and Vite also deploy, but this implementation uses Next.js. - Node.js and npm: Install a current supported Node.js release with npm. The Webflow Cloud docs list npm as the only supported package manager.
- Stripe account and Price: Create a Stripe product and Price, and obtain the matching secret key. Use test mode while validating the flow.
- Cookie signing secret: Generate a long random string for signing the member cookie, and protect it exactly like the Stripe secret key.
These prerequisites give the app a supported runtime, a sellable Stripe Price, and the secrets needed to issue verifiable access.
5 steps to gate a dashboard after Stripe payment in Webflow Cloud
This flow creates Checkout on the server, validates Stripe's returned session, signs a scoped cookie, enforces it at the edge, and connects the deployed application to Webflow.
Build the payment and access flow in the following order.
1. Install the Stripe SDK and set the Webflow Cloud environment variables
Before the first route reaches the repo, the deploy target should already hold every secret. In your Next.js project, run npm install stripe.
Keep the environment variables in the deploy target from the first commit; both secret and non-secret variables are available to the build process and to the deployed app at runtime, and secrets are redacted from build logs, so there is no reason to stage them anywhere else.
Add the required values as environment variables for the Webflow Cloud environment you deploy to, and store the sensitive ones as secrets.
Add these environment values:
- STRIPE_SECRET_KEY: Use the Stripe secret key for the current mode. Mark it secret so it never prints in a build log.
- STRIPE_PRICE_ID: Set the
price_...identifier for the product. Keeping it here lets you switch Price without editing code, though the new value still reaches the app only after a deploy. - MEMBER_COOKIE_SECRET: Store the random string that signs and verifies the member cookie. Rotating it immediately logs out every member.
- NEXT_PUBLIC_BASE_PATH: Set the application's mount path, such as
/app. Webflow Cloud generates the framework's ownbasePathconfig, but it does not create this variable; you add it yourself, and manually constructed URLs read it.
NEXT_PUBLIC_BASE_PATH is intentionally non-secret because the NEXT_PUBLIC_ prefix exposes it to browser code. Never put a Stripe key, cookie secret, or other credential in a variable with that prefix.
Add the same values to a local .env.local for development, with the test-mode Stripe key. After a save and redeploy, process.env.STRIPE_SECRET_KEY resolves inside a Route Handler on the deployed app, and the Stripe SDK import compiles with no runtime directive added to any file.
2. Create the Stripe Checkout Session from a Route Handler
Create a GET Route Handler that creates the Stripe Checkout Session and points its success_url to the app's return route. Stripe replaces the {CHECKOUT_SESSION_ID} placeholder in success_url with the real session ID when it redirects the customer.
The server-created Checkout Session points that URL to the app's mount path, giving the return route a session to verify. The handler answers GET on purpose: the Buy button on the Webflow page will be an ordinary link, and a link cannot POST without custom code.
Create the handler at app/api/checkout/route.ts:
// app/api/checkout/route.ts
import Stripe from 'stripe';
import { NextResponse } from 'next/server';
export async function GET(request: Request) {
const stripeSecretKey = process.env.STRIPE_SECRET_KEY!;
const stripe = new Stripe(stripeSecretKey);
const origin = new URL(request.url).origin;
const base = process.env.NEXT_PUBLIC_BASE_PATH ?? '';
const session = await stripe.checkout.sessions.create({
mode: 'payment',
customer_creation: 'always',
line_items: [{ price: process.env.STRIPE_PRICE_ID!, quantity: 1 }],
success_url: `${origin}${base}/api/checkout/return?session_id={CHECKOUT_SESSION_ID}`,
cancel_url: `${origin}/pricing`,
});
return NextResponse.redirect(session.url!, 303);
}
No httpClient override is needed. Current stripe-node ships worker and workerd export conditions, so a Workers bundle already resolves to the fetch-based client and the SubtleCrypto provider; passing them explicitly is a leftover from older versions.
Webhook verification is the one place the runtime still shows through, because SubtleCrypto is async and constructEvent is not. The handler derives the origin from the incoming request, so the generated return URL follows the origin serving the request without an edit
This checkout route is intentionally unauthenticated and has no rate limit or session-creation abuse control. It creates Checkout Sessions, and an attacker can still generate requests and consume service resources.
Add authentication or an application-level rate limit before using it in a higher-risk deployment. CSRF protection isn't used because the route doesn't act with an existing authenticated browser session, and OAuth state is inapplicable because there is no OAuth flow.
Hitting /app/api/checkout in a browser with your real mount path now redirects you to a Stripe-hosted Checkout page showing your product and price.
3. Verify the paid session and set a signed member cookie
Create the shared cookie helper and return route so they fetch the Checkout Session from Stripe and inspect its payment_status. Abandoned and paid Checkout flows both produce session IDs. Only a paid value should trigger a cookie.
The cookie itself is a base64url payload plus an HMAC-SHA256 signature over it, produced with Web Crypto so the same code runs in a Route Handler, in middleware, and under local next dev.
The helper both routes share lives in lib/member-cookie.ts:
// lib/member-cookie.ts
const encoder = new TextEncoder();
const decoder = new TextDecoder();
export type MemberClaims = { customerId: string; exp: number };
function toBase64Url(bytes: ArrayBuffer | Uint8Array): string {
const view = bytes instanceof Uint8Array ? bytes : new Uint8Array(bytes);
let binary = '';
for (const b of view) binary += String.fromCharCode(b);
return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
}
function fromBase64Url(value: string): Uint8Array {
const standard = value.replace(/-/g, '+').replace(/_/g, '/');
const padded = standard.padEnd(standard.length + ((4 - (standard.length % 4)) % 4), '=');
return Uint8Array.from(atob(padded), (c) => c.charCodeAt(0));
}
async function hmacKey(secret: string, usage: 'sign' | 'verify') {
return crypto.subtle.importKey(
'raw',
encoder.encode(secret),
{ name: 'HMAC', hash: 'SHA-256' },
false,
[usage],
);
}
export async function signMemberCookie(claims: MemberClaims, secret: string): Promise<string> {
const body = toBase64Url(encoder.encode(JSON.stringify(claims)));
const key = await hmacKey(secret, 'sign');
const signature = await crypto.subtle.sign('HMAC', key, encoder.encode(body));
return `${body}.${toBase64Url(signature)}`;
}
export async function verifyMemberCookie(
value: string | undefined,
secret: string,
): Promise<MemberClaims | null> {
if (!value) return null;
try {
const [body, signature] = value.split('.');
if (!body || !signature) return null;
const key = await hmacKey(secret, 'verify');
const valid = await crypto.subtle.verify(
'HMAC',
key,
fromBase64Url(signature),
encoder.encode(body),
);
if (!valid) return null;
const claims = JSON.parse(decoder.decode(fromBase64Url(body))) as MemberClaims;
return claims.exp > Date.now() ? claims : null;
} catch {
return null;
}
}
Verification uses crypto.subtle.verify, which compares signatures without manual byte comparison.
That choice handles the runtime gap: crypto.subtle.timingSafeEqual is a Cloudflare extension that exists on the deployed Workers runtime but not in local next dev. So a hand-rolled constant-time comparison passes in production and throws on your laptop, or vice versa. Letting Web Crypto do the comparison sidesteps the gap.
Create the return handler at app/api/checkout/return/route.ts:
// app/api/checkout/return/route.ts
import Stripe from 'stripe';
import { NextResponse } from 'next/server';
import { signMemberCookie } from '@/lib/member-cookie';
const THIRTY_DAYS_SECONDS = 60 * 60 * 24 * 30;
export async function GET(request: Request) {
const stripeSecretKey = process.env.STRIPE_SECRET_KEY!;
const stripe = new Stripe(stripeSecretKey);
const url = new URL(request.url);
const base = process.env.NEXT_PUBLIC_BASE_PATH ?? '';
const sessionId = url.searchParams.get('session_id');
if (!sessionId) {
return NextResponse.redirect(`${url.origin}/pricing`, 303);
}
const session = await stripe.checkout.sessions.retrieve(sessionId);
if (session.payment_status !== 'paid') {
return NextResponse.redirect(`${url.origin}/pricing?status=unpaid`, 303);
}
const customerId =
typeof session.customer === 'string' ? session.customer : session.customer?.id ?? 'unknown';
const cookie = await signMemberCookie(
{ customerId, exp: Date.now() + THIRTY_DAYS_SECONDS * 1000 },
process.env.MEMBER_COOKIE_SECRET!,
);
const response = NextResponse.redirect(`${url.origin}${base}/dashboard`, 303);
response.cookies.set('member', cookie, {
httpOnly: true,
secure: true,
sameSite: 'lax',
path: base || '/',
maxAge: THIRTY_DAYS_SECONDS,
});
return response;
}
Add an application-level rate limit to the return route before using it in a higher-risk deployment. CSRF tokens don't address bearer-ID reuse, and OAuth state doesn't apply. High-value or account-specific access requires buyer authentication, an expected-purchase record, single redemption, and webhook-driven revocation.
Client-side fetch calls must manually include the Webflow Cloud base path. Here, the redirect targets the dashboard inside the mounted app, so the URL assembled in the Route Handler includes that path; a bare /dashboard could instead target the site's /dashboard path. Scoping the cookie's path to the mount path keeps it off every marketing page request.
Completing a test payment now sets a member cookie and redirects to /app/dashboard, which does not exist yet and returns a 404. That 404 is the proof the return route worked: check the browser's cookie store for member before moving on. The dashboard and its gate arrive in step 4.
4. Gate the dashboard route with middleware.ts on the edge runtime
Create middleware.ts at the project root to gate the dashboard route. That filename and location are a Next.js file convention, not a Webflow rule; Webflow Cloud's only constraint here is the runtime.
Middleware runs on the Edge runtime by default and needs no runtime export. Only Edge runtime middleware works on the Workers runtime; Node.js runtime middleware isn't supported.
Gate the dashboard in one file at the project root:
// middleware.ts
import { NextResponse, type NextRequest } from 'next/server';
import { verifyMemberCookie } from '@/lib/member-cookie';
export async function middleware(request: NextRequest) {
const member = await verifyMemberCookie(
request.cookies.get('member')?.value,
process.env.MEMBER_COOKIE_SECRET!,
);
if (member) return NextResponse.next();
const base = process.env.NEXT_PUBLIC_BASE_PATH ?? '';
const pricing = new URL(`/pricing?next=${encodeURIComponent(`${base}/dashboard`)}`, request.url);
return NextResponse.redirect(pricing, 303);
}
export const config = {
matcher: ['/dashboard/:path*'],
};
The matcher targets the dashboard path inside the app. The redirect for an anonymous visitor goes to the Webflow-side pricing page and carries a next parameter your Buy button can ignore or honor later.
The dashboard page itself re-reads the cookie and denies access if verification fails, so a future refactor that moves or bypasses the middleware does not expose the customer identifier:
// app/dashboard/page.tsx
import { cookies } from 'next/headers';
import { verifyMemberCookie } from '@/lib/member-cookie';
export default async function Dashboard() {
const store = await cookies();
const member = await verifyMemberCookie(
store.get('member')?.value,
process.env.MEMBER_COOKIE_SECRET!,
);
if (!member) {
return (
<main>
<h1>Access denied</h1>
<p>A valid member cookie is required.</p>
</main>
);
}
return (
<main>
<h1>Your dashboard</h1>
<p>Signed in as Stripe customer {member.customerId}</p>
</main>
);
}
The middleware and page checks each perform an HMAC verification and prevent a matcher edit from quietly exposing the customer identifier. A successful payment and a private-window request confirm both behaviors.
5. Link the Webflow Buy button to the app and deploy
Set the Designer button to a plain link to /app/api/checkout after you substitute your mount path. The marketing page requires neither an embed nor a Stripe script tag. The Designer keeps owning the layout while the app owns the transaction.
Publish the Webflow site, then deploy the app to the Webflow Cloud environment holding the required variables. Before deployment, confirm that the configured base path exactly matches the application's mount path.
Run these deployment checks:
- Click Buy on the published page: In Stripe test mode, use a standard test card and confirm Checkout shows your product and price.
- Complete the test payment: Confirm Stripe briefly redirects to
/app/api/checkout/return?session_id=cs_test_...before the return route verifies payment and issues the cookie. - Land on the dashboard: Verify the URL reads
/app/dashboardand the page shows acus_...customer ID from Stripe. - Open the dashboard in a private window: With no cookie, confirm the request redirects to
/pricingand protected content stays hidden.
You should now have a published Buy button that completes Checkout, issues a signed cookie, opens the dashboard, and rejects a private browser without that cookie.
What causes the Stripe redirect to fail on Webflow Cloud?
Failures usually come from an unsupported runtime directive, a missing middleware gate, an incorrect mount path, or a cookie that the dashboard request cannot send or verify. Start by comparing the deployed environment, generated Stripe URLs, middleware file, and browser cookie against these symptoms.
The Webflow Cloud deploy fails with an unsupported runtime error
Cause: This shows up after adding export const runtime = 'edge' to a Route Handler. The word "edge" means two different things in the Webflow Cloud docs, and the same page uses both. Webflow Cloud runs your app on Cloudflare Workers, an edge platform.
The Next.js edge runtime target, set with export const runtime = 'edge', is separate, and the OpenNext Cloudflare adapter Webflow Cloud deploys through doesn't support it. The bring-your-own-app page still tells you to add the directive to API routes, so a developer following the docs faithfully ships a broken build.
Fix: Remove every export const runtime = 'edge' line from Route Handlers and pages, redeploy, and let the adapter place the code. If a route still fails after the removal, search the repo for runtime in layout.tsx files too, since a directive in a layout can apply to the routes under it.
Anyone can open /app/dashboard with no cookie
Cause: One possible trigger is renaming a file from middleware.ts to proxy.ts, often via a codemod or an upgrade guide. Newer Next.js releases introduce proxy.ts as the successor to middleware.ts. On Webflow Cloud, only Edge runtime middleware works on the Workers runtime, and proxy runs on the Node runtime with no way to opt into Edge.
As a result, proxy.ts cannot provide the middleware gate on Webflow Cloud. A second trigger is a dashboard page that renders protected data after cookie verification returns null.
Fix: Rename the file back to middleware.ts at the project root and keep the exported function named middleware. Keep the page-level null check so the component also fails closed.
After redeploying, open the dashboard in a private window and confirm it redirects to pricing. The page check prevents protected content from rendering if middleware is bypassed.
Stripe returns the customer to the wrong path on your domain after payment
Cause: The success_url Stripe received omitted the mount path, or NEXT_PUBLIC_BASE_PATH in the deployed environment differs from where the app is mounted. The success_url in this flow targets a return route inside the mounted app, so it has to be built with NEXT_PUBLIC_BASE_PATH.
Stripe redirects to exactly the URL it was given, so a missing /app sends the customer outside the mounted app's return route. The payment has already gone through at this point, which makes the incorrect redirect especially painful.
Fix: Open the Stripe Dashboard, find the Checkout Session, and read its success_url. Compare it to the URL the return route actually answers on. Set NEXT_PUBLIC_BASE_PATH to the exact mount path with a leading slash and no trailing slash, redeploy, and create a fresh session; existing sessions keep their old URL.
Do not send customers raw session IDs or manually open their sessions through an unauthenticated return route; recover access through an authenticated support process that verifies the buyer and purchase.
Payment succeeds, but the dashboard bounces straight back to pricing
Cause: The cookie was set on the return response and not read by the middleware. On Webflow Cloud,d possible reasons are a path that does not cover the dashboard, such as path: '/dashboard' when the real path is /app/dashboard, a different MEMBER_COOKIE_SECRET in the environment the middleware runs in than the one that signed the cookie, or secure: true on a local http://localhost run, where the browser does not store the cookie.
Another cause appears if someone replaced crypto.subtle.verify with crypto.subtle.timingSafeEqual: that Cloudflare extension exists on the deployed Workers runtime but not under local next dev, so verification throws in one place and passes in the other.
Fix: Open the browser's developer tools, go to the Application tab, and inspect the cookie on the dashboard request. If it is absent, check the response headers on the return route for Set-Cookie and fix the path to the mount path.
If it is present but the redirect still fires, the secret differs between the signer and the verifier; set the same value in every environment and redeploy both. For local testing, keep secure: true and run against https, or gate secure on process.env.NODE_ENV === 'production'.
What you can build next with Stripe and Webflow
Your paying customers need the dashboard to open as soon as Stripe confirms the charge, and this build hands it over then. Production can extend this dashboard with buyer-bound access controls and role-gated access once a real identity provider is in place.
Explore the Stripe integration to connect Stripe and Webflow.
For deeper customization beyond what Checkout's return redirect handles natively, Webflow's developer docs cover environment variables and the platform limits, including the flat 20-second request timeout and 30 seconds of Worker CPU that apply on every plan.
Frequently asked questions
Does this implementation distinguish between multiple Stripe products?
No. This implementation creates Checkout with the single Price stored in STRIPE_PRICE_ID and issues the same member cookie after any verified paid session it creates. To distinguish products, you would need to verify the expected purchase before granting the corresponding access. Keep separate entitlements buyer-bound, stored server-side, and updated by webhooks for refunds or cancellations.
How do I revoke dashboard access after a refund or cancellation?
Use a Stripe webhook and a server-side revocation store. Your Route Handler should verify signatures with constructEventAsync and SubtleCrypto, reject stale timestamps and duplicate event IDs, then write the customer ID to the store. Your middleware checks that record with the cookie. Until then, shorten the cookie lifetime to reduce delayed revocation.
Does this flow work for subscriptions instead of one-time payments?
Yes. You should change Checkout to mode: 'subscription' and use a recurring Price in line_items. Keep checking for payment_status === 'paid' when immediate payment is required. Note what a free trial does here: Stripe documents paid as covering subscriptions with a trial, where "the $0 trial invoice has been successfully processed," so a payment_status === 'paid' check admits trialing customers rather than excluding them. no_payment_required belongs to setup mode sessions and billing-cycle-anchor sessions. If trials should not open the dashboard, test the subscription's status explicitly instead of relying on payment_status.





