Back to blog

Add Smart Shipping Rules to Your Next.js Checkout

Learn how to implement conditional free shipping at checkout by setting minimum cart thresholds and targeting specific countries.

Darko M.·

Free shipping promotions sound simple until you actually wire them up. "Free shipping on orders over $100, US only" turns into a tangle of conditionals that lives in your checkout component, your API route, and probably your analytics pipeline too. “Let’s try $75 for the weekend” sounds simple, until it becomes a PR, and yet another deployment in the constant churn of promos and price tests.

This post walks through a cleaner shape: keep the shipping logic in a Nodora rule, call it from a Next.js API route, and let the UI react to whatever the rule returns. Change the threshold or add a country, redeploy the rule, and the checkout updates: no app redeploy needed.

Start with a basic rule

rule FreeShipping {
  out threshold = 100
  out eligible_countries = ["us"]
  out eligible = input.country in eligible_countries && input.total >= threshold
}

Three outputs, one expression. threshold and eligible_countries are exposed so the UI can render messages like "Add $12 more to unlock free shipping" without duplicating the numbers. eligible is the actual decision.

When you need to change the threshold, add a country, or introduce a date-based promotion, just update the rule and deploy. No changes to the application logic are required.

The API route

A thin route handler forwards the cart context to the rule and returns its outputs:

// app/api/checkout/route.ts
import { checkout } from "@/app/lib/nodora";

export async function POST(request: Request) {
  const { total, country } = await request.json();

  const result = await checkout.evaluate("FreeShipping", { total, country });

  return Response.json(result.outputs);
}

The checkout 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 checkout = nodora.ruleset("Checkout");

The stale-while-revalidate strategy with a 60-second TTL means the system immediately evaluates requests using a cached version of the rule, even if it’s slightly out of date. At the same time, it fetches the latest rule updates in the background.

This way, users never have to wait for a refresh or experience downtime when rules change: updates like pricing tests, shipping thresholds, or promotion changes quietly propagate in the background and take effect once refreshed.

The cart UI

The client component calls the API whenever the cart total or destination country changes, and uses the rule outputs to drive what the user sees:

useEffect(() => {
  fetch("/api/checkout", {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify({ total, country }),
  })
    .then((res) => res.json())
    .then((data) => setFreeShipping(data));
}, [total, country]);

The state is derived directly from the rule. You can use this state to conditionally render shipping options, for example:

{freeShipping?.eligible && (
  <label className="...">
    <input type="radio" name="shipping" value="free" ... />
    Free Shipping
  </label>
)}

Why split it this way

The component does not know what makes someone eligible for free shipping. It knows there are outputs from a rule and how to render them. That separation is the whole point:

  • Marketing changes are rule edits. Threshold tweaks, country expansions, seasonal promotions —> all live in the ruleset.
  • The UI stays declarative. The UI is purely a projection of rule output. Adding new dimensions such as promo_code or expiry_date should ideally only require updates to the rule logic, preventing conditional spaghetti from leaking into multiple components.
  • The logic is testable in one place. Nodora rules are evaluated the same way in dev, prod, and tests. You can change the rule without touching the storefront and verify the outputs directly.

What to try next

A few directions the same pattern handles cleanly:

  • Tiered shipping. Add a discount output for orders between $50 and $100, and a free output for $100+. The component renders whichever tier applies.
  • Country-specific thresholds. Replace the eligible_countries list with a map of { "us": 100, "ca": 150, "gb": 120 } and look up input.country in the rule.
  • Time-bound promotions. Pass input.now into the rule and gate eligibility on a date range. The promotion ends automatically without a code deploy.

The full demo

The complete working demo can be found here.