A Webflow Cloud route can show live Shippo carrier quotes on product pages, as estimates beside the shipping Webflow charges at checkout.
Shoppers want to know what delivery costs before they add to cart. Webflow Ecommerce charges shipping from rules you set per zone, and Webflow's Shippo integration handles labels after the order, so neither shows live carrier prices on the product page.
I close that gap with a small Next.js app on Webflow Cloud. It keeps your Shippo token on the server and returns live rates for the shopper's address, cheapest first. The quotes are estimates because Webflow checkout still charges what your shipping settings say, and no API lets a custom app change that charge.
What do you need to set up Shippo shipping rates in Webflow?
You need five things:
- Webflow Ecommerce site: A Webflow site set up to support Ecommerce and at least one product page where the rate form will sit.
- Webflow Cloud access: Included from the free Starter site plan up.
- A plan for the custom domain and the embed: Mounting the app on a custom domain requires Premium or higher, and the product page's Code Embed needs a paid plan, which every Ecommerce plan covers.
- Shippo account with an active carrier: At least one carrier account that supports rate quotes, plus a test API token for local development.
- Node.js 22 and npm: Installed locally, with npm as the package manager, since Webflow Cloud builds with npm only.
With these prerequisites in place, you are ready to configure the Shippo API route and wire up your Webflow product page.
6 steps to set up Shippo shipping rates in Webflow Cloud
One POST route on Webflow Cloud calls Shippo's shipments endpoint, and a product-page form calls that route. Because the app shares the store's domain, the call is same-origin and needs no CORS headers.
Set up the token and app first, then wire the form and deploy.
1. Generate a Shippo test token
In Shippo, open API Configuration and create a test key under Developer keys. Test keys start with shippo_test_ and live keys with shippo_live_, and Shippo shows the full key only once. Build against the test key until the whole flow works, so nothing you try while building touches your live account.
While you are in the dashboard, confirm at least one carrier account is active. Shippo quotes rates through the carrier accounts connected to your account, so an account with no active carrier has nothing to quote.
Store the token in a password manager and keep it out of notes files.
2. Scaffold a Next.js app with npm
Create the app with create-next-app pinned to version 15 and force npm as the package manager. Webflow's bring-your-own-app guide states: "Currently, Webflow Cloud supports only the npm package manager." Pinning version 15 keeps middleware.ts working on Webflow Cloud, which matters if you add rate limiting later.
Run this from the folder where you keep projects:
npx create-next-app@15 shippo-rates --typescript --app --use-npm
cd shippo-rates
npm run dev
The command installs Next.js 15 with the App Router, where route handlers live. The route calls Shippo's REST API with plain fetch, which avoids installing a Shippo SDK that might expect Node APIs unavailable on Workers.
Open http://localhost:3000 in a browser. You should see the default Next.js starter page, which confirms the project compiles before any Shippo code goes in.
3. Store the Shippo token and ship-from address as environment variables
Keep the Shippo token and ship-from details in environment variables, outside the route's code. Locally, that means a .env.local file in the project root; on Webflow Cloud, it means the environment settings for each environment you deploy to, with the token marked as a secret.
The route requires the first two variables, and the other two are optional:
- SHIPPO_API_TOKEN: The test token from the Shippo dashboard, swapped for a live token in production. Mark it secret, so Webflow Cloud redacts it from build logs.
- SHIP_FROM_ADDRESS: Your warehouse or studio address as a JSON string, so you can change the origin without redeploying code or editing the route.
- ALLOWED_ORIGIN: The exact origin of your storefront, such as your custom domain with https. The route compares it against the browser's Origin header and skips the check when it is unset.
- NEXT_PUBLIC_BASE_PATH: Optional in this build: the mount path for any client components inside the Next.js app, since client-side fetch calls are not rewritten to include it automatically.
Add these values to .env.local, with the JSON wrapped in single quotes:
SHIPPO_API_TOKEN=shippo_test_your_token_here
SHIP_FROM_ADDRESS='{"name":"Your Store","street1":"Your street address","city":"Austin","state":"TX","zip":"78701","country":"US"}'
ALLOWED_ORIGIN=http://localhost:3000
NEXT_PUBLIC_BASE_PATH=
When you paste SHIP_FROM_ADDRESS into Webflow Cloud, paste only the JSON, without the surrounding single quotes. Webflow's environment variables page says both secret and plain variables are available at build time and at runtime, and secret values are redacted from build logs.
4. Write the rates route handler
Create app/api/rates/route.ts. The handler checks the origin, validates the destination address, selects a parcel from a server-side preset list, and sends a single request to Shippo's shipments endpoint. The request sets async: false, which makes Shippo return the rates in the shipment response.
Field names follow Shippo's shipment reference. Shippo allows 50 shipment requests per minute on a test key and 500 on a live one, and returns a 429 past that, which the route passes on so the form can ask the shopper to wait.
The origin check filters casual cross-site use, but scripted clients can forge headers, and requests with no Origin header, such as curl, also pass. Add rate limiting in middleware.ts before public deployment. The token never gets a NEXT_PUBLIC_ name, so it stays on the server.
Route handler code
Create the rates route with this handler:
// app/api/rates/route.ts
import { NextResponse } from "next/server";
// Do not add `export const runtime = "edge"` to this file. Webflow Cloud already
// runs the route on Cloudflare Workers, and the OpenNext adapter does not support
// the Next.js edge runtime target.
type Parcel = {
length: string;
width: string;
height: string;
distance_unit: "in";
weight: string;
mass_unit: "lb";
};
// Parcel presets live on the server so shoppers cannot send fake weights.
const PARCELS: Record<string, Parcel> = {
small: { length: "8", width: "6", height: "4", distance_unit: "in", weight: "1", mass_unit: "lb" },
medium: { length: "12", width: "10", height: "6", distance_unit: "in", weight: "3", mass_unit: "lb" },
};
const REQUIRED_FIELDS = ["name", "street1", "city", "state", "zip", "country"] as const;
type ShippoRate = {
object_id: string;
amount: string;
currency: string;
provider: string;
servicelevel?: { name?: string };
estimated_days?: number | null;
};
export async function POST(request: Request) {
const allowedOrigin = process.env.ALLOWED_ORIGIN;
const origin = request.headers.get("origin");
if (allowedOrigin && origin && origin !== allowedOrigin) {
return NextResponse.json({ error: "Origin not allowed" }, { status: 403 });
}
const token = process.env.SHIPPO_API_TOKEN;
const fromRaw = process.env.SHIP_FROM_ADDRESS;
if (!token || !fromRaw) {
return NextResponse.json({ error: "Shipping is not configured" }, { status: 500 });
}
let addressFrom: Record<string, string>;
try {
addressFrom = JSON.parse(fromRaw);
} catch {
return NextResponse.json({ error: "SHIP_FROM_ADDRESS is not valid JSON" }, { status: 500 });
}
let body: { parcel?: string; to?: Record<string, unknown> };
try {
body = await request.json();
} catch {
return NextResponse.json({ error: "Request body must be JSON" }, { status: 400 });
}
const parcel = body.parcel ? PARCELS[body.parcel] : undefined;
if (!parcel) {
return NextResponse.json({ error: "Unknown parcel" }, { status: 400 });
}
const to = body.to ?? {};
const addressTo: Record<string, string> = {};
for (const field of REQUIRED_FIELDS) {
const value = to[field];
if (typeof value !== "string" || value.trim() === "" || value.length > 100) {
return NextResponse.json({ error: `Missing or invalid field: ${field}` }, { status: 400 });
}
addressTo[field] = value.trim();
}
let shippoRes: Response;
try {
shippoRes = await fetch("https://api.goshippo.com/shipments/", {
method: "POST",
headers: {
Authorization: `ShippoToken ${token}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
address_from: addressFrom,
address_to: addressTo,
parcels: [parcel],
async: false,
}),
signal: AbortSignal.timeout(15000),
});
} catch (err) {
console.error("Shippo request failed", err);
return NextResponse.json({ error: "Rate lookup timed out" }, { status: 504 });
}
if (shippoRes.status === 429) {
return NextResponse.json({ error: "Too many rate requests, try again shortly" }, { status: 429 });
}
if (!shippoRes.ok) {
console.error("Shippo error", shippoRes.status, await shippoRes.text());
return NextResponse.json({ error: "Rate lookup failed" }, { status: 502 });
}
const shipment = (await shippoRes.json()) as { rates?: ShippoRate[]; messages?: unknown[] };
const rawRates = shipment.rates ?? [];
if (rawRates.length === 0 && shipment.messages?.length) {
console.warn("Shippo returned no rates", JSON.stringify(shipment.messages));
}
const rates = rawRates
.map((rate) => ({
id: rate.object_id,
amount: rate.amount,
currency: rate.currency,
provider: rate.provider,
service: rate.servicelevel?.name ?? "",
days: rate.estimated_days ?? null,
}))
.sort((a, b) => parseFloat(a.amount) - parseFloat(b.amount));
return NextResponse.json({ rates });
}
If the browser sent weight and dimensions, anyone could request rates for arbitrary parcels on your Shippo account. With async: false, Shippo waits for every carrier before responding, so the 15-second timeout keeps a slow carrier within Webflow Cloud's 20-second request limit.
Testing the route locally
With npm run dev running, test the route from a second terminal:
curl -X POST http://localhost:3000/api/rates \
-H "Content-Type: application/json" \
-d '{"parcel":"small","to":{"name":"Test Buyer","street1":"1 Market St","city":"San Francisco","state":"CA","zip":"94105","country":"US"}}'
You should get back JSON with a rates array, cheapest first, each entry carrying a provider, service name, amount, and currency. If the array comes back empty, check carrier setup in Shippo before changing the route.
5. Add the rate form to your Webflow product page
Add a Code Embed to the product template where shoppers check delivery cost. The form calls a relative path under the app's mount path. Rates depend on the destination, so the form asks for a full address; if your carriers quote by postal code alone, you can trim it to the ZIP, state and country fields and fill placeholders on the server.
Paste this into the embed and set MOUNT_PATH to the path you will mount the app on:
<form id="rate-form">
<input name="name" placeholder="Full name" required>
<input name="street1" placeholder="Street address" required>
<input name="city" placeholder="City" required>
<input name="state" placeholder="State or region" required>
<input name="zip" placeholder="ZIP or postal code" required>
<input name="country" placeholder="Country code, e.g. US" maxlength="2" required>
<button type="submit">Get shipping rates</button>
</form>
<p class="rate-note">Estimated carrier rates. Final shipping is calculated at checkout.</p>
<ul id="rate-list"></ul>
<script>
const MOUNT_PATH = "/shipping"; // must match the Webflow Cloud mount path
const PARCEL_KEY = "small"; // must match a key in PARCELS in route.ts
const form = document.getElementById("rate-form");
const list = document.getElementById("rate-list");
form.addEventListener("submit", async (event) => {
event.preventDefault();
list.textContent = "Fetching rates...";
const to = Object.fromEntries(new FormData(form));
try {
const res = await fetch(MOUNT_PATH + "/api/rates", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ parcel: PARCEL_KEY, to: to }),
});
const isJson = (res.headers.get("content-type") || "").includes("application/json");
const data = isJson ? await res.json() : {};
if (!res.ok) throw new Error(data.error || "Request failed (" + res.status + ")");
list.innerHTML = "";
const rates = data.rates || [];
if (rates.length === 0) {
list.textContent = "No rates returned for this address.";
return;
}
for (const rate of rates) {
const li = document.createElement("li");
const days = rate.days != null ? " (" + rate.days + " days)" : "";
li.textContent = rate.provider + " " + rate.service + ": " + rate.amount + " " + rate.currency + days;
list.appendChild(li);
}
} catch (err) {
list.textContent = err.message;
}
});
</script>
Every product shares one template, so one embed means one PARCEL_KEY for the whole catalog; to vary it per product, drive the value from a product-level field instead of hardcoding it, and add any new size to PARCELS first.
Place the embed below the add-to-cart button. The note above the list matters: shoppers would otherwise read the first quote as the checkout charge. The form will not return rates until the app is deployed and mounted. Publish the page and check that the address fields and button are visible.
6. Deploy the app and compare quotes with checkout shipping
Deploy the app through your Webflow Cloud project and mount it on your custom domain at the same path set in MOUNT_PATH.
Confirm the three required environment variables exist in the environment you are deploying to, with ALLOWED_ORIGIN set to the storefront's HTTPS origin and, if you added client components, NEXT_PUBLIC_BASE_PATH set to the mount path.
Publish the Webflow site, open a live product page, and submit a real address. Once test-key quotes look right, swap SHIPPO_API_TOKEN for your live key in the production environment only.
If you connected your own carrier accounts while testing, reconnect them under the live key, because test-mode carrier accounts don't carry over and test rates may differ from live ones.
Request quotes for a few representative destinations for each parcel preset, and compare them to what your checkout charges. If the two drift apart, adjust your store's shipping methods; weight-based methods track carrier pricing most closely, since Webflow has no carrier-calculated option.
I keep a short list of test addresses for this and rerun it whenever carrier pricing changes.
What causes Shippo shipping rates to fail in Webflow Cloud?
Most failures come from the build or the mount path; the rest come from credentials or a shipment that returns no quotes. The error the form shows tells you where to start.
Deploy fails even though the route works under next dev
Cause: The route file contains export const runtime = 'edge'. Webflow Cloud deploys Next.js through the OpenNext Cloudflare adapter, which does not support the Next.js edge runtime target.
The confusion comes from Webflow's own bring-your-own-app page, which still tells readers to add that directive to API routes. The same page also says Webflow Cloud "deploys your app using the Edge runtime," and that sentence describes the platform: Cloudflare Workers, an edge network. The Next.js runtime setting is a separate switch, and flipping it breaks the build.
Fix: Delete the directive from every route handler and redeploy. The route already runs on Workers without it. Keep the comment at the top of route.ts so the next developer who reads the docs does not add it back.
The rate form shows "Request failed (404)" on the live site
Cause: The request is going to /api/rates on the storefront root instead of under the app's mount path. Webflow Cloud injects the base path into the Next.js build, but browser fetch calls are not rewritten, and Webflow's docs say client-side fetch calls must manually include the base path. The Webflow page embed sits entirely outside the app, so nothing adds the prefix for it.
Fix: Set MOUNT_PATH in the embed to the exact mount path, including the leading slash, and republish the Webflow site.
For client components inside the Next.js app, build URLs from process.env.NEXT_PUBLIC_BASE_PATH instead of reading basePath from next.config. Open the browser's network tab to confirm the request URL now includes the prefix.
The live rate form shows "Shipping is not configured" or "Rate lookup failed"
Cause: The deployed environment is missing SHIPPO_API_TOKEN, has it under a different name, or holds a token that Shippo no longer accepts. Locally, Next.js reads .env.local, a file that never reaches Webflow Cloud.
Webflow Cloud sets variables per environment, so check that the token exists in production as well as staging.
The two branches return different errors. A missing or misnamed variable makes the route return a 500 with "Shipping is not configured". A wrong token makes Shippo reject the call, which the route logs and returns as a 502 with "Rate lookup failed".
Fix: Open the production environment in your Webflow Cloud project, confirm the variable name matches the code exactly, and paste the token again with no surrounding spaces. Check that a live deployment uses a shippo_live_ key, then redeploy so the new value is picked up.
The form returns "No rates returned for this address"
Cause: Shippo accepted the request but sent back no quotes. The route logs Shippo's messages array so you can read Shippo's explanation of the empty result directly.
Fix: Find the "Shippo returned no rates" line in your app's runtime output; under local next dev it prints in the terminal running the server.
If it names a carrier account error, fix that account in the Shippo dashboard. If it names the address, rerun the curl request from your terminal with that destination and a complete street address. If only one preset fails, compare its dimensions with what the message text reports.
What you can build next with Shippo and Webflow
Once shoppers can see carrier prices, per-product parcel presets are the natural next step, so each quote matches the box that item ships in; map each product slug to a preset key on the server and keep the units in line with your store's unit system.
For fulfillment beyond labels, the Printful bridge shows how a Webflow Cloud app can carry orders out and shipping updates back.
For the platform side, Webflow's developer docs cover how Webflow Cloud runs Next.js, including its middleware rules.
Frequently asked questions
Will the form work for addresses outside the US?
Only where the address has a state or region. REQUIRED_FIELDS in route.ts includes state, so the route rejects any address with an empty state field. For countries without states, remove "state" from REQUIRED_FIELDS and drop required from that input in the embed. Shippo's API docs list any extra fields international shipments require.
Can I load parcel presets from a JSON file with node:fs?
No. Webflow Cloud's Workers runtime has no file system access, as its Node.js compatibility page notes. Import the JSON as a module so it bundles at build time, or store presets in an environment variable.
Where should rate limiting go if I upgrade to Next.js 16?
For working rate limits on Webflow Cloud, I recommend keeping middleware.ts, even though Next.js 16 renames it to proxy.ts. Proxy runs on Node, and Webflow's docs state: "Only Edge runtime middleware works on the Workers runtime." Renaming the file produces middleware that cannot run on Webflow Cloud, leaving the rates route without rate limiting.
Will a timingSafeEqual check work if I add a signed webhook route later?
It works once deployed because timingSafeEqual exists on the Workers runtime Webflow Cloud uses. It does not exist under local next dev, so the same code can throw on your machine. Guard the call with a feature check and test the comparison on a staging deployment rather than relying on local results alone.





