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.
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_codeorexpiry_dateshould 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
discountoutput for orders between $50 and $100, and afreeoutput for $100+. The component renders whichever tier applies. - Country-specific thresholds. Replace the
eligible_countrieslist with a map of{ "us": 100, "ca": 150, "gb": 120 }and look upinput.countryin the rule. - Time-bound promotions. Pass
input.nowinto 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.