Building a B2B wholesale portal unlocks a personalized, high-value experience where every customer sees the exact pricing, terms, and catalog relevant to their business. Combine Shopify's B2B features with Webflow's powerful front end to create a seamless, secure, dynamic storefront that builds trust and fosters stronger, long-term B2B relationships.
Imagine one buyer signs in and sees their negotiated rate. Another signs in and sees a different one. A logged-out visitor should see neither, and none of those three responses can be reused for anybody else.
That single requirement is what separates this build from a normal Shopify and Webflow integration. Public catalog pages can be fetched once and reused; a wholesale price list belongs to exactly one company and must be fetched fresh, per buyer, every time.
This guide covers what Shopify B2B gives you, which parts of it depend on your plan, and how to put a Webflow front end in front of it without leaking one customer's pricing to another.
What do you need to build a B2B wholesale portal with Shopify and Webflow?
You need a Shopify store with B2B set up, customer accounts instead of legacy logins, a Storefront API token, and a server-side layer for authenticated calls. The customer accounts requirement catches most migrations.
Have these ready before you start:
- A Shopify store on a plan that supports the B2B features you need, with at least one company and company location created
- Customer accounts enabled, because Shopify states plainly that B2B only works with customer accounts and legacy login code has to be replaced
- A Storefront API access token, plus access to the Customer Account API for the buyer login flow
- A Webflow Cloud project running a Next.js app, plus Node.js 22.13.0 or higher locally, the floor set by Webflow CLI 2.0
The plan question deserves more than a line in a checklist, because common advice is out of date and it determines whether per-customer pricing is even possible.
Which Shopify plan does a B2B wholesale portal need?
Shopify B2B is no longer restricted to Plus. It runs on Basic, Grow, Advanced, and Plus, and most core features come with all of them, which changes the shape of many wholesale projects.
What still separates the tiers is how specific your pricing can get. Below Plus, you can assign up to three active B2B catalogs across your markets; on Plus, you get unlimited catalogs and, more importantly, the ability to assign a catalog directly to one company or company location.
Here is where the lines fall:
| B2B capability | Basic and Grow | Advanced | Plus |
|---|---|---|---|
| Companies, company locations and location permissions | Yes | Yes | Yes |
| B2B catalogs assignable to markets | Up to 3 active | Up to 3 active | Unlimited |
| Direct company catalogs, meaning pricing set per company | No | No | Yes |
| Quantity rules and quantity price breaks | Yes | Yes | Yes |
| Net payment terms, PO numbers and draft order to invoice | Yes | Yes | Yes |
| Contextual checkout and storefront through Shopify Markets | No | Yes | Yes |
| Deposits, partial payments and payment per fulfillment | No | No | Yes |
| B2B capability → Basic and Grow → Advanced → Plus |
|---|
| Companies, company locations and location permissions |
| Yes |
| Yes |
| Yes |
| B2B catalogs assignable to markets |
| Up to 3 active |
| Up to 3 active |
| Unlimited |
| Direct company catalogs, meaning pricing set per company |
| No |
| No |
| Yes |
| Quantity rules and quantity price breaks |
| Yes |
| Yes |
| Yes |
| Net payment terms, PO numbers and draft order to invoice |
| Yes |
| Yes |
| Yes |
| Contextual checkout and storefront through Shopify Markets |
| No |
| Yes |
| Yes |
| Deposits, partial payments and payment per fulfillment |
| No |
| No |
| Yes |
If your wholesale model is a handful of pricing tiers that many customers share, you can build it below Plus. If every account has individually negotiated rates, direct company catalogs are how that works, and they are a Plus feature.
Note too that using B2B catalog features below Plus requires the store to be on new Shopify Markets.
5 steps to build the wholesale portal on Webflow Cloud
The build is an authentication flow, a location selector, and a set of product queries that carry buyer context. Each query returns data for a single customer.
Follow them in order, because each step produces a value the next one needs.
1. Authenticate the buyer through customer accounts
B2B pricing is only visible to an authenticated buyer, so login comes first. Shopify's headless B2B guidance says B2B works with customer accounts, and you must update existing code written against legacy customer accounts before any of this works.
This flow outputs a Customer Accounts access token, which the rest of the build refers to as the customerAccessToken. It represents one person at one company, and you obtain it at the end of an authorization flow they complete rather than minting it server-side.
Treat that token as a credential from the moment you receive it. It should never reach a URL or a log line, and if you hold it in an HTTPOnly session cookie rather than passing it up from the browser, it never reaches client JavaScript either.
You finish this step when a signed-in buyer produces a token your server can hold for the length of their session.
2. Retrieve the company location the buyer is ordering for
A token alone is not enough context. One person may buy for several sites of the same business, and each location can carry different pricing, so Shopify needs to know which one this order belongs to.
Query the Customer Account API for the locations the customer can access, and present them as a selector if there is more than one. Store the chosen companyLocationId for the rest of the session, because every contextualized request needs it alongside the token.
If a buyer has exactly one location, select it silently, but keep the value in the same place rather than special-casing it. A working step ends with a token and a location ID held together as the buyer's context.
3. Contextualize the product queries
This is where wholesale pricing appears. Adding a buyer argument to the @inContext directive tells the Storefront API which customer and location a query is being asked on behalf of, and the response changes accordingly.
A contextualized query returns the negotiated price rather than the retail one. It gives you access to the quantity rules and quantity price breaks that make wholesale ordering work: minimums, maximums, order increments, and the tiered rates that reward larger quantities.
The query below asks for all three:
# Without the buyer argument this returns public retail pricing.
# With it, you get the pricing this company location has negotiated.
query B2BProduct(
$handle: String!
$customerAccessToken: String!
$companyLocationId: ID!
) @inContext(
buyer: {
customerAccessToken: $customerAccessToken
companyLocationId: $companyLocationId
}
) {
product(handle: $handle) {
title
variants(first: 20) {
nodes {
id
title
price {
amount
currencyCode
}
quantityRule {
minimum
maximum
increment
}
quantityPriceBreaks(first: 10) {
nodes {
minimumQuantity
price {
amount
currencyCode
}
}
}
}
}
}
}
Run the same query without the buyer argument to get public retail pricing, which is a useful way to confirm the context is being applied rather than assumed. When this step is right, two different buyers requesting the same product handle receive different prices.
4. Serve the queries from a route that cannot be cached
Everything above produces a response belonging to one company, which makes caching the single most dangerous mistake available in this build. Shopify flags it directly: if you cache these responses, other users could see another customer's B2B pricing, so you must turn off caching on routes that return buyer-specific data.
That warning lands harder on Webflow Cloud than on a traditional server, because it runs on Cloudflare Workers, where the framework, fetch or a proxy can introduce a cache without anyone deciding to add one. A cached wholesale route does not fail loudly. It serves one customer's negotiated rates to whoever asks next.
Mark the route dynamic, pass cache: 'no-store' to the upstream fetch, and send private cache headers back:
// app/api/catalog/[handle]/route.ts
import { NextRequest, NextResponse } from 'next/server'
import { cookies } from 'next/headers'
// This route returns one buyer's negotiated pricing. It must never be
// cached, at the edge or anywhere else.
export const dynamic = 'force-dynamic'
export async function GET(
request: NextRequest,
{ params }: { params: Promise<{ handle: string }> }
) {
const { handle } = await params
// Read the buyer context from an httpOnly session cookie rather than
// from client-supplied headers, so the token never touches the browser.
const jar = await cookies()
const token = jar.get('sl_customer_token')?.value
const locationId = jar.get('sl_company_location')?.value
if (!token || !locationId) {
return NextResponse.json({ error: 'No buyer context' }, { status: 401 })
}
const res = await fetch(
`https://${process.env.SHOPIFY_DOMAIN}/api/2026-07/graphql.json`,
{
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Shopify-Storefront-Access-Token': process.env
.SHOPIFY_STOREFRONT_TOKEN as string,
},
body: JSON.stringify({
query: B2B_PRODUCT_QUERY,
variables: {
handle,
customerAccessToken: token,
companyLocationId: locationId,
},
}),
cache: 'no-store',
}
)
const data = await res.json()
return NextResponse.json(data, {
headers: { 'Cache-Control': 'private, no-store' },
})
}
The first two overlap by design, since force-dynamic already sets every fetch in the segment to no-store, so the explicit option is belt-and-braces rather than a second requirement. The response header is genuinely separate, because it tells a proxy or CDN in between.
Also note that in Next.js 16 with Cache Components enabled, the dynamic export is removed, and the route has to opt out through fetch and the header instead.
Verify it rather than trusting it, by signing in as two buyers on different pricing and confirming the second never sees the first's numbers.
5. Build the cart with buyer identity attached
A contextualized catalog is only half the job. The cart has to carry the same identity, or a buyer browses at wholesale rates and checks out at retail ones.
Pass the customerAccessToken and companyLocationId as the buyer identity when you create the cart, and update an existing cart with the same pair if the buyer switches location mid-session.
That carries B2B rules and pricing through to checkout, which is why you can't simply reuse a cart created before sign-in afterward.
Test the full path, not just the catalog. The step is complete when a line item's checkout price matches the price the buyer saw on the product page for a buyer whose rate isn't the public one.
What causes a Shopify B2B portal to fail on Webflow Cloud?
Four failures are worth checking first: retail prices where wholesale should be, a cached response serving the wrong customer, a plan that cannot do per-company pricing, and a build broken by a runtime directive.
The pricing ones are easy to mistake for each other, so check them in this order:
Buyers see retail pricing instead of their own
Cause: The query is not contextualized. Either the buyer argument is missing from the @inContext directive, or one of its two values is absent, since a buyer with access to more than one company location needs the location ID to resolve which pricing applies.
Fix: Confirm both the customerAccessToken and the companyLocationId are reaching the query, then compare the same request with and without the buyer argument. If both return identical prices, the context isn't being applied at all, not applied incorrectly, which points to the request rather than the catalog configuration in Shopify.
Check that the token hasn't expired while you are there, since an expired token fails in the same quiet way and produces the same retail fallback rather than an authentication error.
One customer sees another customer's prices
Cause: A cached response. Shopify warns that contextualized B2B responses are personalized, so caching them lets other users see another customer's pricing; on the Workers runtime, the framework, fetch, or a proxy can introduce a cache without anyone deciding to.
Fix: Force the route dynamic, set cache: 'no-store' on the upstream call, and return private no-store headers. Then test it deliberately with two accounts on different pricing rather than assuming, because this failure produces no error and looks exactly like a working page to whoever built it.
It is the one defect here with commercial consequences outside your own logs.
Per-company pricing cannot be assigned
Cause: The store is below Plus. Catalogs assigned directly to a company or company location are a Plus feature, and on Basic, Grow and Advanced you get up to three active catalogs assigned to markets instead.
Fix: Decide whether the pricing model genuinely needs per-account rates or whether a small number of shared tiers would serve. Plenty of pricing that gets described as individually negotiated turns out to be a small number of shared tiers once it is written down.
If it really is per-account, that is a Plus conversation rather than an engineering one, and it's worth having before the portal is built, not after. Nothing in the front end can synthesize pricing the API will not return, so this constraint has to be settled on the Shopify side first.
The build fails after adding the API routes
Cause: An export const runtime = 'edge' directive. Webflow Cloud deploys Next.js through the OpenNext Cloudflare adapter, which does not support the Next.js edge runtime.
Fix: Remove the line and redeploy. Worth checking first whenever a Webflow Cloud build breaks straight after new routes appear, because Webflow's own bring-your-own-app page still tells Next.js readers to add the directive, so the instruction you followed may be the cause.
Search the whole project rather than the file you last touched, since one stray directive anywhere fails the build. This bites B2B projects more than most, because the authentication and catalog routes tend to be lifted from headless examples written for other platforms, and those examples carry the directive as a matter of course.
What you can build next with Shopify and Webflow
Once buyer context flows through the front end, the portal can handle the rest of the wholesale relationship: reorder from past invoices, purchase order numbers at checkout, and quantity rules that prevent an order from being rejected after it is placed.
If you need a public catalog rather than an authenticated one, that is a different, simpler build, and our Shopify Storefront API guide covers syncing products into the Webflow CMS. Store both API tokens as environment variables marked as secrets. For the connection routes that need no code, see the Webflow and Shopify integration.
Frequently asked questions
Does Shopify B2B require Plus?
No. B2B runs on Basic, Grow, Advanced and Plus, and companies, catalogs, net terms and quantity rules are available on all of them. Plus adds unlimited catalogs, direct per-company pricing, and advanced payment options. Below Plus, B2B catalog features require the store to be on new Shopify Markets.
Why can't I cache the wholesale catalog?
Because the response belongs to one buyer. Shopify warns that caching contextualized B2B responses can show another customer's pricing. Disable caching on any route that carries buyer context, and test with two accounts on different rates.
What makes a query return wholesale pricing?
The buyer argument on the @inContext directive, carrying a customerAccessToken and a companyLocationId. The token is required, and the location ID is optional in the schema, though in practice you need it whenever a buyer can order for more than one location, since it decides whose pricing applies.
Can I use my existing customer login code?
Not if it was written for legacy customer accounts. Shopify states that B2B only works with customer accounts, so the authentication flow has to be updated before any contextualized query returns the right prices.
How is this different from syncing Shopify products to Webflow?
A product sync publishes one public catalog everyone sees, and caching it is desirable. A wholesale portal returns different data per buyer, requires authentication, and must not be cached. They are opposite problems.




