How to add customer-specific pricing to a Next.js B2B portal
This tutorial builds a small B2B store in Next.js that shows every customer their own prices: contract prices, a volume break and a group discount, resolved on the server by PriceRules. Every code block comes from the finished store, pricerules-nextjs-example, which is public: clone it and follow along, or copy the pieces into your own portal.
You'll need Node 20.9 or later and a free PriceRules account. The store runs on the PriceRules sample data, so the prices below are the ones you'll see.
The problem: discounts hard-coded in checkout
Most B2B storefronts start with one price per product. Then the first big account signs a contract, and a special case lands in checkout:
checkout.ts (before)TypeScriptexport function unitPrice(customer: string, sku: string, quantity: number, listPrice: number) { if (customer === "ACME-BUILD" && sku === "FAS-1001") { // Contract: 12% off bolts, 18% from 50 boxes return listPrice * (quantity >= 50 ? 0.82 : 0.88); } if (customer === "ACME-BUILD" && sku === "TOL-2001") return 129; // until Dec 31? return listPrice; }It works for one customer. By the tenth, the same special cases are copied into the product page, the cart, the quote PDF and the ERP export, each a little different. Changing a contract means a deploy, nobody knows when the drill price was supposed to end, and sales can't see what a customer actually pays without asking an engineer.
The fix is to move the rules out of the code. Keep products, customers and pricing rules in one place, and have every page ask the same question: what does this customer pay for this SKU at this quantity? That's the question the PriceRules API answers, and the one the store in this tutorial asks on every page.
Sign up and load the sample data
Create a free PriceRules account. During onboarding, choose the sample data: an industrial supplies catalog with 27 products, 8 customers and 24 pricing rules. Already have a workspace? Open the Pricing Sandbox and choose Load sample data.
The store uses three of the sample accounts:
ACME-BUILD: 12% off bolts by contract, 18% from 50 boxes, the drill at a fixed $129.00 (list $149.00), and 10% off valves as a member of theCONTRACTORSgroup.SUMMIT-ENG: the same contractors' valve discount, plus 25% off gloves from 100.HARBOR-SUP: 10% off safety glasses until the end of next month, a promotion with an end date.
Then open the API page and create a key. It's shown once: keep it for the next step.
Install the SDK
In your own Next.js app (App Router), install the official SDK:
TerminalShellnpm install pricerulesOr run the finished store and read along:
TerminalShellgit clone https://github.com/dalisraieb/pricerules-nextjs-example.git cd pricerules-nextjs-example cp .env.example .env.local npm install npm run devEither way, put your key in
.env.localasPRICERULES_API_KEY. It's a server-side secret: never give it aNEXT_PUBLIC_prefix, which would ship it to every browser..env.localEnv# Your PriceRules API key: dashboard → API → Create key. Server-side only: # never prefix it with NEXT_PUBLIC_. PRICERULES_API_KEY= # Optional: another PriceRules API base URL (default https://pricerules.app/api/v1). # PRICERULES_BASE_URL=Then give the app one module that talks to the API. Importing
server-onlyturns any import from a Client Component into a build error, so the key can't end up in browser code. The client reads the key from the environment itself:lib/pricerules.tsTypeScriptimport "server-only"; import { unstable_rethrow } from "next/navigation"; import { PriceRules, PriceRulesError, type ProductInput, type ResolveData } from "pricerules"; import { errorSentence, type PriceError } from "./price-error"; // The only module that talks to PriceRules. `server-only` makes importing it // from a Client Component a build error, so the API key can't reach the // browser: pages call it while rendering on the server, and the cart goes // through this app's own POST /api/cart. let client: PriceRules | undefined; function pricerules(): PriceRules { // Reads PRICERULES_API_KEY from the environment; throws MISSING_API_KEY // without it. PRICERULES_BASE_URL is optional (a local PriceRules or a mock). client ??= new PriceRules({ baseUrl: process.env.PRICERULES_BASE_URL || undefined }); return client; }Price the catalog in a Server Component
Add one function that prices a list of lines for one customer. It never throws: a failure comes back as a value the page can render, so a pricing problem never becomes a blank page.
lib/pricerules.tsTypeScriptexport type PriceResult = | { ok: true; data: ResolveData } | { ok: false; error: PriceError }; /** * Prices every line for one customer in a single call. `customerId` null is a * guest: only rules for every customer apply. Never throws: a failure comes * back as a PriceError the page can show. */ export async function priceLines( customerId: string | null, products: ProductInput[], ): Promise<PriceResult> { try { const { data } = await pricerules().resolve({ customerId: customerId ?? undefined, products, }); return { ok: true, data }; } catch (error) { // When Next.js ends a prerender it rejects pending fetches; the SDK wraps // that in its connection error. Hand Next.js its own errors back. if (error instanceof PriceRulesError && error.cause) unstable_rethrow(error.cause); unstable_rethrow(error); return { ok: false, error: toPriceError(error) }; } }One
resolve()call prices up to 500 products, so the catalog page prices everything it shows in one request while it renders on the server. The priced part sits inside<Suspense>, so the page's shell streams first:app/page.tsxTSXasync function Catalog({ searchParams }: { searchParams: PageProps<"/">["searchParams"] }) { const { category } = await searchParams; const active = findCategory(typeof category === "string" ? category : undefined); const products = active ? PRODUCTS.filter((product) => product.category === active.slug) : PRODUCTS; // One call prices every product on the page for this customer. const customer = await getCustomerCode(); const result = await priceLines( customer, products.map((product) => ({ sku: product.sku, quantity: 1 })), ); const account = findAccount(customer); // … <ul className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4"> {products.map((product, index) => ( <li key={product.sku} className="flex flex-col [&>*]:flex-1"> <ProductCard product={product} view={result.ok ? explainItem(result.data.items[index]) : null} /> </li> ))} </ul>Items come back in the order you sent them, so
items[index]is the product atindex. Each has thefinal_price, thebase_price, theapplied_rule(its name, value and constraints) and amatch_reasonthat says which kind of rule won. The example turns that into what a buyer reads: the saving, the rule's name, and notes like "Volume price from 50 units" or "Contract price until Dec 31":lib/explain.tsTypeScriptexport function explainItem(item: ResolvedItem): PriceView { if (item.match_reason === "PRODUCT_NOT_FOUND") { return { kind: "not_found", sku: item.sku, quantity: item.quantity }; } const saving = Math.max(0, roundCents(item.base_price - item.final_price)); const minimum = item.applied_rule?.constraints.minimum_quantity ?? null; const validTo = item.applied_rule?.constraints.valid_to ?? null; return { kind: "priced", sku: item.sku, quantity: item.quantity, finalPrice: item.final_price, basePrice: item.base_price, saving, savingPercent: item.base_price > 0 ? (saving / item.base_price) * 100 : 0, ruleName: item.applied_rule?.name ?? null, reason: matchReasonText(item), volumeNote: minimum && minimum > 1 ? `Volume price from ${minimum} units` : null, untilNote: validTo ? `Contract price until ${formatDay(validTo)}` : null, }; }Add a customer switcher
The page has to know who's buying. In a real portal, take the customer code from the signed-in user's company, on the server, and never from anything the buyer can edit. The example has no login, so it keeps the selected customer in an httpOnly cookie:
lib/customer-cookie.tsTypeScriptexport const CUSTOMER_COOKIE = "customer"; /** The selected customer's code, or null for a guest. */ export async function getCustomerCode(): Promise<string | null> { const store = await cookies(); return normalizeCustomerCode(store.get(CUSTOMER_COOKIE)?.value); }A Server Action sets it. Setting a cookie in a Server Action re-renders the current page on the server, so every price on it updates without any client-side fetching:
app/actions.tsTypeScript"use server"; import { cookies } from "next/headers"; import { CUSTOMER_COOKIE } from "@/lib/customer-cookie"; import { normalizeCustomerCode } from "@/lib/customers"; /** * Switches the customer the store prices for. Setting the cookie re-renders * the current page on the server, so every price updates. */ export async function setCustomer(value: string | null): Promise<void> { const store = await cookies(); const code = normalizeCustomerCode(value); if (code) { store.set(CUSTOMER_COOKIE, code, { path: "/", httpOnly: true, sameSite: "lax", secure: process.env.NODE_ENV === "production", maxAge: 60 * 60 * 24 * 30, }); } else { store.delete(CUSTOMER_COOKIE); } }The switcher is a Client Component that calls the action in a transition, which keeps the old prices on screen until the new ones arrive:
components/customer-switcher.tsxTSXexport function CustomerSwitcher({ current }: { current: string | null }) { const id = useId(); const [pending, startTransition] = useTransition(); const [editing, setEditing] = useState(false); const [draft, setDraft] = useState(""); const customCode = current && !findAccount(current) ? current : null; function apply(code: string | null) { setEditing(false); startTransition(() => setCustomer(code)); }Switch to
ACME-BUILDand the drill shows $129.00 instead of $149.00, the bolts $16.28 instead of $18.50. Switch to Guest (no customer) and every product goes back to its list price.Price the cart, with a volume break
The cart changes in the browser, but the API key lives on the server. So the cart posts SKUs and quantities to the app's own route handler, which prices them for the customer in the cookie, never for one the request body names:
app/api/cart/route.tsTypeScriptexport async function POST(request: Request) { const lines = parseLines(await request.json().catch(() => null)); if (!lines) { return Response.json( { error: { kind: "api", code: "INVALID_CART", message: "The cart couldn't be read.", requestId: null } }, { status: 400 }, ); } const result = await priceLines(await getCustomerCode(), lines); if (!result.ok) { return Response.json({ error: result.error }, { status: result.error.kind === "setup" ? 500 : 502 }); } return Response.json({ data: result.data }); }The cart sends every line again whenever one changes, after a short pause so five clicks on + make one request:
components/cart-view.tsxTSXuseEffect(() => { if (lines.length === 0) return; const controller = new AbortController(); // A short pause so clicking + five times sends one request. const timer = setTimeout(async () => { setLoading(true); try { const response = await fetch("/api/cart", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ lines }), signal: controller.signal, }); const body: { data?: ResolveData; error?: PriceError } = await response.json(); if (body.data) { setPriced({ lines, data: body.data }); setError(null); } else { setError(body.error ?? networkError); } } catch (caught) { if (controller.signal.aborted) return; console.error(caught); setError(networkError); } setLoading(false); }, 250); return () => { clearTimeout(timer); controller.abort(); }; }, [lines, customer, attempt]);Now add the hex bolts (
FAS-1001, box of 100) asACME-BUILDand step from 49 to 50 boxes. Two rules match that customer and SKU, and from 50 boxes the one with the higher minimum quantity wins:Quantity Unit price Rule applied Line total 49 $16.28 Acme contract – fasteners $797.72 50 $15.17 Acme volume – bolts 50+ $758.50 50 boxes cost $39.22 less than 49. The store holds no quantity logic: the rule's minimum quantity and the engine's choice do it, and
applied_rule.constraints.minimum_quantitylets the page say why.Handle unknown customers and errors
Three things can come back that aren't a clean price. Handle each on purpose:
- An unknown customer code is not an error. The API answers 200, prices the request as an anonymous customer (only rules for everyone apply) and adds a
CUSTOMER_NOT_FOUNDwarning todata.warnings. Show list prices and say why. - A SKU the workspace doesn't have comes back with
match_reason: "PRODUCT_NOT_FOUND"and null prices, and the rest of the request is still priced. The example shows "Price on request" for its new arrival,VLV-350. - A failed call throws a
PriceRulesErrorwith acodeand arequestId. By then the SDK has already retried what's safe to retry: rate limits, a busy pricing engine, connection errors.
The pages render one component above their prices for the first and last case:
components/pricing-notices.tsxTSXexport function PricingNotices({ result }: { result: PriceResult }) { if (!result.ok) { return <PriceErrorNotice error={result.error} action={result.error.kind === "api" && <RetryButton />} />; } const unknown = result.data.warnings.some((warning) => warning.code === "CUSTOMER_NOT_FOUND"); if (unknown && result.data.customer) return <CustomerNotFoundNotice code={result.data.customer.id} />; return null; }And
priceLines()turns an error into something safe to send to the browser: a code, a sentence and the request id, never the key or a stack trace. Log the request id, and show it: it's what support needs to find the call.lib/pricerules.tsTypeScriptfunction toPriceError(error: unknown): PriceError { if (error instanceof PriceRulesError) { if (error.code === "MISSING_API_KEY") return { kind: "setup" }; console.error( `PriceRules ${error.code} (status ${error.status ?? "none"}, request ${error.requestId ?? "none"}): ${error.message}`, ); return { kind: "api", code: error.code, message: errorSentence(error.code), requestId: error.requestId ?? null, }; } console.error("Unexpected error while pricing", error); return { kind: "api", code: "UNEXPECTED", message: errorSentence(""), requestId: null }; }To see the warning, pick "Another customer code…" in the switcher and type
NEW-CO.- An unknown customer code is not an error. The API answers 200, prices the request as an anonymous customer (only rules for everyone apply) and adds a
Deploy with Vercel
The store needs one environment variable. The Deploy button in the example's README clones the repo into your GitHub account and asks for
PRICERULES_API_KEYon the way:For your own app, add the variable and deploy from the project folder:
TerminalShellnpx vercel env add PRICERULES_API_KEY production npx vercel --prodThe catalog and the cart read the customer cookie, so they render per request and nothing is cached across customers. A rule you change in the dashboard reaches the API seconds later, with no deploy: try changing the bolts' volume rule and reload the cart.