Skip to content
Guide

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.

  1. 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)TypeScript
    export 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.

  2. 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 the CONTRACTORS group.
    • 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.

  3. Install the SDK

    In your own Next.js app (App Router), install the official SDK:

    TerminalShell
    npm install pricerules

    Or run the finished store and read along:

    TerminalShell
    git clone https://github.com/dalisraieb/pricerules-nextjs-example.git
    cd pricerules-nextjs-example
    cp .env.example .env.local
    npm install
    npm run dev

    Either way, put your key in .env.local as PRICERULES_API_KEY. It's a server-side secret: never give it a NEXT_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-only turns 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.tsTypeScript
    import "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;
    }
  4. 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.tsTypeScript
    export 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.tsxTSX
    async 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 at index. Each has the final_price, the base_price, the applied_rule (its name, value and constraints) and a match_reason that 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.tsTypeScript
    export 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,
      };
    }
  5. 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.tsTypeScript
    export 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.tsxTSX
    export 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-BUILD and 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.

  6. 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.tsTypeScript
    export 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.tsxTSX
      useEffect(() => {
        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) as ACME-BUILD and 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:

    QuantityUnit priceRule appliedLine total
    49$16.28Acme contract – fasteners$797.72
    50$15.17Acme 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_quantity lets the page say why.

  7. 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_FOUND warning to data.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 PriceRulesError with a code and a requestId. 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.tsxTSX
    export 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.tsTypeScript
    function 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.

  8. 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_KEY on the way:

    For your own app, add the variable and deploy from the project folder:

    TerminalShell
    npx vercel env add PRICERULES_API_KEY production
    npx vercel --prod

    The 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.

Next steps