Back to blog

How to Implement Location-Based Pricing in Next.js

Learn how to build a dynamic location-based pricing model in Next.js using the Nodora rule engine.

Darko M.·

Selling internationally rarely means selling at the same price everywhere. Purchasing power varies, and a $99 plan that converts in the US is too steep in some markets and underpriced in others. The usual response, a stack of if (country === "DE") ... branches sprinkled across the storefront works until the day finance asks for a regional sale.

What if we made this cleaner: move the pricing table into a Nodora rule, derive the country from Cloudflare’s CF-IPCountry header, and let the UI simply render whatever the rule returns.

The rule

The rule lives in a ruleset called Pricing on the Nodora platform:

rule LocalPrice {
  out price = match input.country {
      "us" => 99,
      "gb" => 79,
      c when c in ["de", "fr", "es"] => 89,
      _ => 99,
  }
}

One match expression, one decision per country, with a wildcard arm catching everything else. Country groups are expressed by binding the scrutinee to c and gating the arm with a when c in [...] guard.

When finance wants to add Australia at 149 or run a sale in Germany, you edit the rule and deploy. The app logic does not change.

Resolve the country at the edge

Cloudflare populates CF-IPCountry on every request, set to the visitor's ISO 3166-1 alpha-2 code (US, DE, …) or XX when unknown. A thin API route reads the header and forwards it to the rule:

// app/api/price/route.ts
import { headers } from "next/headers";
import { pricing } from "@/app/lib/nodora";

export async function GET() {
  const country = (await headers()).get("cf-ipcountry")?.toLowerCase() ?? "xx";

  const result = await pricing.evaluate("LocalPrice", { country });

  return Response.json(result.outputs);
}

The pricing import is a small wrapper around NodoraClient:

// app/lib/nodora.ts
import { NodoraClient } from "@nodora/client";

export const nodora = new NodoraClient({
  apiKey: process.env.NODORA_API_KEY!,
  env: "production",
  strategy: { type: "stale-while-revalidate", ttl: 60_000 },
});

export const pricing = nodora.ruleset("Pricing");

CF-IPCountry is only set in production behind Cloudflare. The ?? "xx" fallback above means local dev hits the wildcard arm and gets the default price.

Render the price

The page calls the route on load and renders the output:

// app/components/PriceTag.tsx
"use client";

import { useEffect, useState } from "react";

export default function PriceTag() {
  const [price, setPrice] = useState<number | null>(null);

  useEffect(() => {
    fetch("/api/price")
      .then((res) => res.json())
      .then((data) => setPrice(data.price));
  }, []);

  if (price === null) return <div className="h-8 w-20 animate-pulse rounded bg-zinc-100" />;

  return <div className="text-3xl font-semibold">${price}</div>;
}

The component knows nothing about which countries get which price. It reads one number from the rule and renders it. Adding a market is a one-line change to the rule.

What to try next

A few directions the same pattern handles cleanly:

  • PPP-adjusted tiers. Group countries into tier1, tier2, tier3 with c when c in [...] guards, then key the price off the tier.
  • Time-bound sales. Pass input.now and apply a discount inside a date range. The sale ends automatically when the window closes.
  • Per-region currency. Add a currency output and let Intl.NumberFormat handle the display.