A consumer store has one price for everyone. A B2B store almost never does. A distributor who moves a pallet a week pays less than a facility manager buying six units, and neither of them should see what the other pays. On top of that, the public shouldn't see any of it — competitors read your price list too, and a wholesale number sitting in the page source is a gift to them.
So "show the price" turns into three questions at once: who is asking, what is their price, and how do I make sure the wrong person never sees a number they shouldn't. Most e-commerce tutorials answer none of these, because consumer commerce doesn't have the problem. This is the gap I keep running into on B2B work, and the Pi-Pi deal portal is where I worked most of it out — an internal sales tool where every deal carries its own buyer, its own wholesale tier, and its own paperwork.
This article is the engineering behind gating prices: server-side access control so nothing leaks, per-account price lists, and a data model where the price on the screen and the price on the invoice can't disagree.
The Leak You Can't See in the Browser
Start with the failure mode, because it's the one people get wrong.
The naive gate is a client-side conditional: fetch the price, and hide it in the component if the user isn't logged in.
// DON'T DO THIS — the price is already in the payload
function ProductPrice({ product, user }: Props) {
if (!user) return <span>Log in to see pricing</span>;
return <span>{formatPrice(product.wholesalePrice)}</span>;
}
This looks gated. The anonymous visitor sees "Log in to see pricing." But product.wholesalePrice was already serialized into the page — in the server-rendered HTML, in the hydration payload, in the JSON your API returned. Anyone who opens DevTools, reads the page source, or hits the API directly gets the number. Googlebot gets it too, and now your wholesale price is indexed.
The rule that follows from this: a price the current user isn't allowed to see must never leave the server. Not hidden in the DOM, not sitting in a props blob, not one curl away. The gate belongs on the server, before the data is fetched — not in the component, after.
Next.js App Router makes this natural, because Server Components run the fetch server-side and only the rendered output crosses to the client. The discipline is: resolve the viewer first, then decide what to even query.
// app/products/[slug]/page.tsx
import { getViewer } from "@/lib/auth";
import { getPriceForViewer } from "@/lib/pricing";
export default async function ProductPage({ params }: { params: Promise<{ slug: string }> }) {
const { slug } = await params;
const viewer = await getViewer(); // null for anonymous / public crawlers
const product = await getPublicProduct(slug); // name, specs, images — safe for everyone
// Price is fetched ONLY when there's an authenticated account to price for.
const price = viewer ? await getPriceForViewer(product.id, viewer) : null;
return (
<article>
<ProductHeader product={product} />
{price ? <PriceBlock price={price} /> : <PriceGate />}
</article>
);
}
The important line is viewer ? await getPriceForViewer(...) : null. For an anonymous request, the price query never runs. There is no wholesale number in the response to leak, because the server never asked for one. <PriceGate /> renders a call to log in — and that's all the HTML contains. This is the same instinct behind resolving auth before you touch tenant data; I wrote about the request-scoped version of it in JWT vs sessions vs OAuth for SaaS auth.
Separate Public Product Data From Priced Data
The gate above works because I split the fetch in two. That split is worth making explicit in the data model, not just the query.
Public product data — name, description, technical specs, images, SKU — is safe for anyone, and you want crawlers to index it. Priced data — the number a specific account pays — is not. Keeping them in separate tables means the public path physically cannot touch the priced path.
-- Public: crawlable, one row per product, no prices
CREATE TABLE products (
id uuid PRIMARY KEY,
slug text NOT NULL UNIQUE,
name text NOT NULL,
spec jsonb NOT NULL DEFAULT '{}'
);
-- Priced: never reached without an authenticated account
CREATE TABLE price_list_items (
price_list_id uuid NOT NULL REFERENCES price_lists(id),
product_id uuid NOT NULL REFERENCES products(id),
unit_price numeric(12, 2) NOT NULL,
currency char(3) NOT NULL DEFAULT 'EUR',
PRIMARY KEY (price_list_id, product_id)
);
getPublicProduct reads products and stops. There is no join to price_list_items on that path, so there is no way for a price to ride along by accident. When you're chasing down a leak six months from now, "the public query structurally can't select a price" is a much stronger guarantee than "we remembered to strip the price field in every serializer."
Per-Account Price Lists
Now the second question: what price. In B2B this is rarely "the price of the product" — it's the price of the product for this account, at this quantity.
The model that scales without turning into a mess of per-customer rows is a price list the account is assigned to, with quantity breaks inside it. Several accounts share a list; a specific account can override it. This mirrors how the Pi-Pi portal handles it: wholesale tiers live in one table, and the buyer gets the right price for their volume without anyone pricing it by hand.
CREATE TABLE price_lists (
id uuid PRIMARY KEY,
name text NOT NULL,
currency char(3) NOT NULL DEFAULT 'EUR'
);
-- An account points at a list. Default list for everyone not otherwise assigned.
CREATE TABLE accounts (
id uuid PRIMARY KEY,
company text NOT NULL,
price_list_id uuid NOT NULL REFERENCES price_lists(id)
);
-- Quantity breaks: price depends on how many units are ordered.
CREATE TABLE price_breaks (
price_list_id uuid NOT NULL REFERENCES price_lists(id),
product_id uuid NOT NULL REFERENCES products(id),
min_qty integer NOT NULL,
unit_price numeric(12, 2) NOT NULL,
PRIMARY KEY (price_list_id, product_id, min_qty)
);
Resolving a price is then: find the account's list, find the highest min_qty break that the order quantity clears, take its unit_price.
// lib/pricing.ts
export async function getPriceForViewer(productId: string, viewer: Viewer, qty = 1) {
const breaks = await db.query.priceBreaks.findMany({
where: and(
eq(priceBreaks.priceListId, viewer.account.priceListId),
eq(priceBreaks.productId, productId),
lte(priceBreaks.minQty, qty)
),
orderBy: [desc(priceBreaks.minQty)],
limit: 1,
});
const tier = breaks[0];
if (!tier) return null; // product not sold to this account — treat as "not for sale"
return {
unitPrice: tier.unitPrice,
currency: viewer.account.priceList.currency,
total: tier.unitPrice * qty,
};
}
Two decisions here earn their keep. First, lte(minQty, qty) + desc + limit: 1 is the whole quantity-break logic — the database picks the tier, not a loop in application code. Second, a missing row returns null, and the caller renders that as "not available on your account" rather than €0 or a crash. In B2B, "this account doesn't buy this product" is a normal state, not an error. Fail toward no price shown, never toward a wrong one.
An account with a genuinely bespoke deal gets its own single-account price list. No special-case column, no if account.isSpecial branch — it's the same mechanism, just a list of one.
Snapshot the Price the Moment a Deal Is Made
Here's the part that separates a real B2B system from a demo. Price lists change. A tier gets renegotiated, a currency default flips, a product is repriced. But a quote you sent last month promised a specific number, and a reissued invoice has to still show that number — not whatever the list says today.
If the invoice reads live from price_breaks, then editing the list silently rewrites history. The customer signed for one price and the document now shows another. At a customs border, or in an audit, that's not a cosmetic bug.
The fix is the pattern I lean on across the Pi-Pi portal: when a deal is created, snapshot the priced values into the deal record. The live tables drive new quotes; the snapshot drives this deal forever.
// lib/deals.ts
// Creating a deal copies the resolved price into the deal's own line items.
// After this, the deal is independent of later price-list edits.
export async function createDeal(accountId: string, lines: DraftLine[]) {
const account = await getAccount(accountId);
const items = await Promise.all(
lines.map(async (line) => {
const price = await getPriceForViewer(line.productId, { account }, line.qty);
if (!price) throw new Error(`No price for product ${line.productId} on this account`);
return {
productId: line.productId,
qty: line.qty,
unitPrice: price.unitPrice, // frozen at deal creation
currency: price.currency,
};
})
);
return db.insert(deals).values({ accountId, items, createdAt: new Date() }).returning();
}
This is the same reasoning the Pi-Pi portal uses for buyer identity: the deal snapshots the company, VAT number and address at creation, so a document reissued months later still matches what the customer signed — even if the account record was edited since. Prices deserve the exact same treatment. The live list is for the next quote. The deal is a contract, and a contract doesn't change under you.
Once every line, price and party lives on the deal record, every document — pro forma, commercial invoice, packing list — becomes a pure function of that one record. Change nothing, and they can't disagree with each other, because they all read the same frozen source.
VAT and Reverse Charge Are Part of "The Price"
For an EU B2B store, the number a buyer owes isn't just the unit price. A German company buying from a Finnish seller, with a valid VAT number, pays the net amount and applies reverse charge — VAT flips to zero and a specific note has to appear on the document. Get that wrong and the price on the screen is simply incorrect for that buyer.
I keep this out of the pricing query and in a small, testable function that runs over the resolved line total:
// lib/vat.ts
// Cross-border EU B2B with a valid VAT number → reverse charge, rate 0.
export function applyVat(net: number, buyer: Buyer, seller: Seller) {
const isReverseCharge =
buyer.countryCode !== seller.countryCode && buyer.isEuVatValidated && buyer.isBusiness;
const rate = isReverseCharge ? 0 : vatRateFor(buyer.countryCode);
return {
net,
rate,
vat: Math.round(net * rate * 100) / 100,
isReverseCharge, // drives the "VAT reverse charge, Article 196..." note on the PDF
};
}
The gating and the VAT logic compose cleanly because they're separate concerns: gating decides whether you see a price, per-account lists decide which net price, and VAT decides what's added on top for your country and status. I went deep on the validation side — VIES reliability, caching, the reverse-charge decision — in EU VAT validation in Next.js; here it's just the last transform before a number reaches the buyer.
Don't Leak Through the Side Doors
The page render is gated. That's necessary, not sufficient. A price can still escape through a channel you forgot to check:
-
API routes. If
/api/products/[id]/pricereturns a price based only on the product ID, anyone can enumerate it. Every priced endpoint has to resolve the viewer and price for that viewer — the samegetPriceForViewergate, not a separate, laxer path. - Search and listing pages. A category page that renders a grid of products must not fetch prices for the anonymous case. Same split as the detail page: public fields for everyone, priced fields only when there's an account.
- OG images and metadata. If you render price into a social preview image or a meta description, it's public by definition. Don't.
-
Structured data.
ProductJSON-LD with anoffers.priceputs the number straight into the page for crawlers. On a gated store, either omitoffersfor anonymous requests or don't emit priced structured data at all. - Caching. A priced response cached at the CDN with a shared key can be served to the wrong account. Priced responses are per-account and must be marked private / uncacheable, or keyed by account. This is the one that bites quietly, because it works in testing and leaks under load.
The through-line: the gate isn't a component, it's an invariant. Every path that could emit a price — page, API, image, feed, cache — has to honour the same "resolve the viewer first" rule. One forgotten route undoes all the others.
Where This Is Overkill
Not every store needs this, and reaching for it reflexively is its own mistake.
If your prices are the same for everyone and just require login to view (a members-only shop, not negotiated B2B), you don't need per-account price lists — you need one price list and an auth check. If you have a handful of customers on identical terms, a per-account list per customer is more machinery than a single wholesale_price column with a login gate. And if prices aren't actually secret — plenty of B2B sellers publish list prices and negotiate down privately — then the whole "never leaks to crawlers" apparatus is solving a problem you don't have; index the list price and move on.
The full pattern earns its complexity when three things are all true: prices genuinely differ per account, they're genuinely confidential, and deals have to stay fixed after they're struck. That's the B2B trade case — the one the Pi-Pi portal was built for. Below that bar, use less.
The Whole Thing in One Glance
| Concern | Mechanism |
|---|---|
| Price never leaks | Resolve viewer server-side; don't fetch a price for anonymous at all |
| Public vs priced data | Separate tables; public query structurally can't select a price |
| Per-account pricing | Account → price list → quantity breaks; single-account list for bespoke |
| Quantity breaks | DB picks the tier (min_qty <= qty, highest wins), not app code |
| "Not sold to you" | Missing row → null → "not available", never €0 |
| Deal integrity | Snapshot price + buyer onto the deal; live list is for the next quote |
| VAT / reverse charge | Separate transform over the net; drives the compliance note |
| Side doors | Same gate on API, listings, OG, JSON-LD, cache — it's an invariant |
Takeaways
Gate on the server, before the fetch — not in the component, after. A price hidden in the DOM is still in the DOM. The only reliable gate is not asking the database for a number the current viewer isn't allowed to see.
Split public product data from priced data at the schema level. When the public query physically can't reach a price, you don't have to trust every serializer to strip it. Structure beats vigilance.
Model pricing as per-account lists with quantity breaks. Let the database resolve the tier. A missing price is a valid state — "this account doesn't buy this" — and should render as such, never as zero.
Snapshot the price into the deal. A quote is a promise and an invoice is a record. Both have to survive later edits to the live price list. Freeze the number at deal creation and every document reads from one consistent source.
Treat the gate as an invariant across every exit. Pages, APIs, OG images, structured data, caches — a price can leak through any of them. One rule, everywhere, or it leaks through the one you forgot.
If you're building a B2B store where each account sees its own negotiated pricing and the public sees none of it, the access-control layer is the part worth getting right before you style a single button. It's the kind of work I do on e-commerce projects, and on a gated store it's the difference between a price list your customers trust and one your competitors read for free.
Top comments (0)