Connect a Znode 10 Custom API to a working storefront calculator
Recipe 04 / Znode 10
Connect a Znode 10 Custom API to a working storefront calculator
Build a typed .NET 8 carton calculator, register its controller and gateway route, call it through a validated Next.js server endpoint, and display the result in a complete React form.
- InteractionReact form
- BoundaryNext.js server route
- LogicCustom API service
- ResponseTyped carton plan
The complete implementation
What you are building
A buyer is planning a replenishment order and wants to know how many cartons a quantity would fill. With 25 units and 12 units per carton, the answer is two full cartons, one loose unit, and three cartons in total if the remainder needs its own carton.
This recipe implements the entire path from the form to a Znode 10 Custom API and back. The .NET service owns the arithmetic. A Next.js server route validates input, calls a fixed configured API origin, checks the response, and returns a small typed result to React.
The calculator is deliberately public and stateless. Its quantities are fictional planning limits, and it reads no account data or prices and changes no cart or order. A customer-specific service would also need the existing Znode authentication and authorization flow.
Znode 10 CustomAPISDK / ASP.NET Core on .NET 8 / Next.js 14 App Router
Before you start
- A configured Znode 10 SDK with CustomAPISDK/Custom.Api.Core, CustomAPISDK/Engine.Custom.Api, and WebStoreSDK/apps/webstore. Preserve the existing Store, tenant, gateway, and authentication configuration.
- The inspected Custom API projects target net8.0 with nullable reference types enabled. This C# uses ASP.NET Core and modern records; it is not a .NET Framework 4.8 implementation.
- The inspected frontend uses Next.js 14.2.20, React 18.3.1, and Nx 19.7.0. Restore the checkout's package lock using a Node.js version supported by that SDK; current Znode system requirements specify Node.js 20 or later.
- An existing working HTTPS Custom API origin reachable from the WebStore server. For a local development process only, the server route also permits an HTTP localhost or 127.0.0.1 origin.
- The example storefront page is /en-US/recipes/carton-plan. Use an enabled locale for your Store. Native middleware still resolves the Store and can enforce a Store-wide login requirement.
Step 01
Define the request, response, and calculation
Create a Gcg/Recipes directory in Custom.Api.Core and add this file. CartonPlanRequest names both inputs and their allowed ranges. CartonPlanResponse names each result. The JSON attributes keep the public field names stable.
The planner validates its input even when called outside MVC. Integer division gives the number of full cartons, the remainder gives loose units, and one extra carton is counted only when a remainder exists. It does not calculate weight, dimensions, freight, or carrier packaging rules.
CustomAPISDK/Custom.Api.Core/Gcg/Recipes/CartonPlan.cscsharp
using System;
using System.ComponentModel.DataAnnotations;
using System.Text.Json.Serialization;
namespace Custom.Api.Core.Gcg.Recipes;
public sealed class CartonPlanRequest
{
[Range(1, 10000000)]
[JsonPropertyName("quantity")]
public int Quantity { get; init; }
[Range(1, 10000)]
[JsonPropertyName("unitsPerCarton")]
public int UnitsPerCarton { get; init; }
}
public sealed record CartonPlanResponse(
[property: JsonPropertyName("fullCartons")] int FullCartons,
[property: JsonPropertyName("looseUnits")] int LooseUnits,
[property: JsonPropertyName("totalCartons")] int TotalCartons);
public sealed class CartonPlanner
{
public CartonPlanResponse Calculate(CartonPlanRequest request)
{
ArgumentNullException.ThrowIfNull(request);
Validator.ValidateObject(request, new ValidationContext(request), validateAllProperties: true);
int fullCartons = request.Quantity / request.UnitsPerCarton;
int looseUnits = request.Quantity % request.UnitsPerCarton;
return new CartonPlanResponse(fullCartons, looseUnits, fullCartons + (looseUnits > 0 ? 1 : 0));
}
}
Step 02
Expose one explicit POST action
Place the controller under Custom.Api.Core/Controllers. ApiController applies the model validation attributes before the action executes, so values outside the declared ranges receive a 400 response. The request-size attribute limits the API body to 1,024 bytes.
AllowAnonymous is intentional for this public arithmetic service. Keep that boundary visible: the controller receives only two quantities and returns counts. Do not reuse anonymous access unchanged when adding customer, contract, price, or order data.
CustomAPISDK/Custom.Api.Core/Controllers/GcgCartonPlanController.cscsharp
using Custom.Api.Core.Gcg.Recipes;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
namespace Custom.Api.Core;
[ApiController]
[Route("GcgCartonPlan")]
public sealed class GcgCartonPlanController : ControllerBase
{
private readonly CartonPlanner planner;
public GcgCartonPlanController(CartonPlanner planner)
{
this.planner = planner;
}
// Public arithmetic only: no account data, prices, or writes.
[AllowAnonymous]
[HttpPost("Calculate")]
[RequestSizeLimit(1024)]
[ProducesResponseType(typeof(CartonPlanResponse), 200)]
[ProducesResponseType(typeof(ValidationProblemDetails), 400)]
public ActionResult<CartonPlanResponse> Calculate([FromBody] CartonPlanRequest request)
{
return Ok(planner.Calculate(request));
}
}
Step 03
Register the service and controller assembly
In Engine.Custom.Api/Program.cs, replace the existing builder.Services.AddControllers() statement with the two statements below. The application part explicitly adds the assembly containing the new controller, and the singleton registration supplies its stateless planner.
Keep these statements near the beginning of the file, before the existing service-provider creation and builder.Build(). Retain all other Znode registrations, middleware, authentication configuration, and the existing app.MapControllers() call. The SDK-style project automatically includes the new C# files.
CustomAPISDK/Engine.Custom.Api/Program.cs, existing AddControllers callcsharp
builder.Services.AddControllers()
.AddApplicationPart(typeof(Custom.Api.Core.GcgCartonPlanController).Assembly);
builder.Services.AddSingleton<Custom.Api.Core.Gcg.Recipes.CartonPlanner>();
Step 04
Append the Custom API gateway route
Append this object to the existing Routes array in Engine.Custom.Api/Ocelot/CustomOcelot.json. Preserve the surrounding object, every other route, and valid commas. The standalone route file supplied with the recipe is an insertion fragment, not a replacement for the entire configuration.
Both paths target the calculator action and only POST is allowed. The customapi keys identify the Custom API destination. Deploy the updated route file with the Custom API and use the environment's existing gateway route-synchronization workflow before testing the gateway URL.
For the direct service test in the next step, use the configured Custom API origin. A gateway URL can be used by the frontend only after this route is active and that gateway's policy permits the intended public request.
CustomAPISDK/Engine.Custom.Api/Ocelot/CustomOcelot.json, Routes arrayjson
{
"DownstreamPathTemplate": "/GcgCartonPlan/Calculate",
"UpstreamPathTemplate": "/GcgCartonPlan/Calculate",
"UpstreamHttpMethod": ["POST"],
"Key": "customapi",
"SwaggerKey": "customapi",
"Priority": 1
}
Step 05
Build the API and verify its JSON contract
Restore and build the existing host with its configured Znode package sources. Run it with the environment and launch configuration already used by this SDK. The calculator itself adds no database, credentials, or package dependencies.
Once the host is running, execute the request below against its configured HTTPS origin. The example uses a reserved hostname; replace it with your test service origin. A working response contains exactly the planning values shown in the assertion.
PowerShell at CustomAPISDKpowershell
dotnet restore .\Engine.Custom.Api\Engine.Custom.Api.csproj
if ($LASTEXITCODE -ne 0) { throw "Custom API restore failed." }
dotnet build .\Engine.Custom.Api\Engine.Custom.Api.csproj --no-restore -c Release
if ($LASTEXITCODE -ne 0) { throw "Custom API build failed." }
$cartonApiOrigin = "https://custom-api.example.com"
$cartonPlan = Invoke-RestMethod -Method Post -Uri "$cartonApiOrigin/GcgCartonPlan/Calculate" `
-ContentType "application/json" -Body '{"quantity":25,"unitsPerCarton":12}'
if ($cartonPlan.fullCartons -ne 2 -or $cartonPlan.looseUnits -ne 1 -or $cartonPlan.totalCartons -ne 3) {
throw "The calculator did not return the expected 2 full, 1 loose, 3 total plan."
}
$cartonPlan
Step 06
Create the Next.js server endpoint
Add route.ts at apps/webstore/src/app/api/gcg/carton-plan. The URL exposed to the browser is /api/gcg/carton-plan. The handler accepts only whole quantities within the same limits as the C# request and rejects malformed JSON before contacting the API.
The API origin comes from GCG_CARTON_API_BASE_URL on the server. It cannot be supplied by the browser. The route permits an HTTPS origin with no credentials, path suffix, query, or fragment. It allows HTTP loopback only while NODE_ENV is development.
The fetch has a five-second timeout, rejects redirects, and bypasses caching. Before returning a response, the handler checks nonnegative integer fields, the remainder bound, and the arithmetic relationship to the original request. A malformed or inconsistent upstream result becomes a 502 response.
This handler checks the length of the received text after reading it. Keep the host or proxy request-size limit in place as the network boundary; the Custom API action also enforces its own 1,024-byte body limit.
WebStoreSDK/apps/webstore/src/app/api/gcg/carton-plan/route.tstypescript
import { NextRequest, NextResponse } from "next/server";
export const runtime = "nodejs";
export const dynamic = "force-dynamic";
type CartonPlanRequest = { quantity: number; unitsPerCarton: number };
type CartonPlan = { fullCartons: number; looseUnits: number; totalCartons: number };
function isRequest(value: unknown): value is CartonPlanRequest {
if (typeof value !== "object" || value === null) return false;
const candidate = value as Partial<CartonPlanRequest>;
return Number.isInteger(candidate.quantity) && Number.isInteger(candidate.unitsPerCarton) &&
Number(candidate.quantity) >= 1 && Number(candidate.quantity) <= 10_000_000 &&
Number(candidate.unitsPerCarton) >= 1 && Number(candidate.unitsPerCarton) <= 10_000;
}
function isPlan(value: unknown): value is CartonPlan {
if (typeof value !== "object" || value === null) return false;
const candidate = value as Partial<CartonPlan>;
return [candidate.fullCartons, candidate.looseUnits, candidate.totalCartons]
.every(number => Number.isSafeInteger(number) && Number(number) >= 0);
}
export async function POST(request: NextRequest) {
const raw = await request.text();
if (raw.length > 1024) return NextResponse.json({ error: "Request is too large." }, { status: 413 });
let input: unknown;
try { input = JSON.parse(raw); }
catch { return NextResponse.json({ error: "Send a JSON object." }, { status: 400 }); }
if (!isRequest(input)) {
return NextResponse.json({ error: "Enter whole quantities within the displayed limits." }, { status: 400 });
}
try {
// This destination comes from server configuration, never from the request.
const base = new URL(process.env.GCG_CARTON_API_BASE_URL ?? "");
const localDevelopment = process.env.NODE_ENV === "development" &&
["localhost", "127.0.0.1"].includes(base.hostname) && base.protocol === "http:";
if ((base.protocol !== "https:" && !localDevelopment) || base.username || base.password ||
base.search || base.hash || base.pathname !== "/") throw new Error("Invalid API origin.");
const response = await fetch(new URL("/GcgCartonPlan/Calculate", base), {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ quantity: input.quantity, unitsPerCarton: input.unitsPerCarton }),
cache: "no-store",
redirect: "error",
signal: AbortSignal.timeout(5000)
});
if (!response.ok) throw new Error("The calculator API rejected the request.");
const plan: unknown = await response.json();
if (!isPlan(plan) || plan.looseUnits >= input.unitsPerCarton ||
plan.fullCartons * input.unitsPerCarton + plan.looseUnits !== input.quantity ||
plan.totalCartons !== plan.fullCartons + (plan.looseUnits > 0 ? 1 : 0)) {
throw new Error("The calculator API returned an invalid response.");
}
return NextResponse.json(plan, { headers: { "Cache-Control": "no-store" } });
} catch {
return NextResponse.json({ error: "The calculator is temporarily unavailable. Please retry." }, { status: 502 });
}
}
Step 07
Configure the server-side API origin
Set GCG_CARTON_API_BASE_URL in the existing WebStore server environment. For the inspected local Nx application, add the line below to apps/webstore/.env.local and restart its development process. In hosted environments, use the corresponding server environment setting and restart or redeploy the application.
Use the reachable Custom API origin tested earlier. If using the gateway instead, first verify its route and access policy using the same JSON request. Do not add a NEXT_PUBLIC_ prefix; this value belongs to the server route.
WebStoreSDK/apps/webstore/.env.localdotenv
GCG_CARTON_API_BASE_URL=https://custom-api.example.com
Step 08
Build the complete interactive form
Create CartonPlanner.tsx beside the new page. The form starts with 25 units and 12 units per carton, labels both fields, and applies the same whole-number limits in the browser. While a request is pending, the fieldset is disabled to keep the displayed inputs aligned with that calculation.
Editing either input clears the previous plan. A successful response displays all three named results, while an error appears in the live status region. The page tells the buyer that this calculation does not change the cart or shipping price.
WebStoreSDK/apps/webstore/src/app/[locale]/recipes/carton-plan/CartonPlanner.tsxtsx
"use client";
import { FormEvent, useId, useState } from "react";
type CartonPlan = { fullCartons: number; looseUnits: number; totalCartons: number };
export function CartonPlanner() {
const id = useId();
const [quantity, setQuantity] = useState("25");
const [unitsPerCarton, setUnitsPerCarton] = useState("12");
const [plan, setPlan] = useState<CartonPlan | null>(null);
const [error, setError] = useState<string | null>(null);
const [pending, setPending] = useState(false);
async function calculate(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
setPending(true);
setPlan(null);
setError(null);
try {
const response = await fetch("/api/gcg/carton-plan", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ quantity: Number(quantity), unitsPerCarton: Number(unitsPerCarton) })
});
if (!response.ok) throw new Error("Unable to calculate. Check the values and try again.");
const result: CartonPlan = await response.json();
setPlan(result);
} catch {
setError("Unable to calculate. Check the values and try again.");
} finally {
setPending(false);
}
}
return (
<section aria-labelledby={`${id}-title`}>
<h1 id={`${id}-title`}>Plan your cartons</h1>
<p>Calculate full cartons and leftover units. This tool does not change your cart or shipping price.</p>
<form onSubmit={calculate} aria-busy={pending}>
<fieldset disabled={pending}>
<legend>Quantities</legend>
<label htmlFor={`${id}-quantity`}>Units to pack (1 to 10,000,000)</label>
<input id={`${id}-quantity`} type="number" min="1" max="10000000" step="1" required
value={quantity} onChange={event => { setQuantity(event.target.value); setPlan(null); }} />
<label htmlFor={`${id}-size`}>Units per carton (1 to 10,000)</label>
<input id={`${id}-size`} type="number" min="1" max="10000" step="1" required
value={unitsPerCarton} onChange={event => { setUnitsPerCarton(event.target.value); setPlan(null); }} />
<button type="submit">{pending ? "Calculating..." : "Calculate cartons"}</button>
</fieldset>
</form>
<div role="status" aria-live="polite">
{error && <p>{error}</p>}
{plan && <p>{plan.fullCartons} full cartons, {plan.looseUnits} loose units,
and {plan.totalCartons} cartons in total.</p>}
</div>
</section>
);
}
Step 09
Add the locale-aware demonstration page
Place page.tsx in the same directory as the form. This concrete App Router path is more specific than the existing catch-all content route and keeps the Store's current locale layout and middleware.
For a Store with en-US enabled, open /en-US/recipes/carton-plan. If that Store requires login globally, sign in through its normal flow before opening the page. The public calculator endpoint does not override Store-wide page access rules.
WebStoreSDK/apps/webstore/src/app/[locale]/recipes/carton-plan/page.tsxtsx
import { CartonPlanner } from "./CartonPlanner";
export default function CartonPlanPage() {
return <main className="container mx-auto px-4 py-8"><CartonPlanner /></main>;
}
Step 10
Build and launch the WebStore
From WebStoreSDK, restore the existing lock file and build the webstore project. The page and route add no frontend package. Keep the configured API, tenant, locale, and Store environment values used by the rest of the application.
Launch the existing Nx development target and use the hostname and port configured for this Store. The server-side origin setting must be available to that process.
PowerShell at WebStoreSDKpowershell
npm ci
if ($LASTEXITCODE -ne 0) { throw "WebStore package restore failed." }
npx nx build webstore
if ($LASTEXITCODE -ne 0) { throw "WebStore build failed." }
npx nx dev webstore
Step 11
Check the visible result and both validation layers
Open the page and calculate the default values. Expect 2 full cartons, 1 loose unit, and 3 cartons in total. Change quantity to 24 and calculate again; expect 2 full cartons, 0 loose units, and 2 cartons in total. A quantity of 1 with carton size 12 produces 0 full cartons, 1 loose unit, and 1 carton in total.
Use a direct POST to test values the browser normally blocks, such as zero, fractional numbers, and a missing property. Exercise both the Custom API endpoint and the Next endpoint. Invalid quantities should receive 400 responses rather than reaching a divide-by-zero operation.
Temporarily make the test API destination unavailable and submit a valid request. The form should show its error message and re-enable the controls. Restore the destination, retry, and verify the successful result. Deploy the Custom API, route configuration, and WebStore changes through the environment's existing release workflow.
Verify the complete result
| Check | Action | Expected result |
|---|---|---|
| Remainder | Calculate quantity 25 with unitsPerCarton 12 through the API and storefront. | fullCartons is 2, looseUnits is 1, and totalCartons is 3. |
| Exact multiple | Calculate quantity 24 with unitsPerCarton 12. | fullCartons is 2, looseUnits is 0, and totalCartons is 2. |
| Less than one carton | Calculate quantity 1 with unitsPerCarton 12. | fullCartons is 0, looseUnits is 1, and totalCartons is 1. |
| Upper bounds | Calculate quantity 10,000,000 with unitsPerCarton 10,000. | fullCartons is 1,000, looseUnits is 0, and totalCartons is 1,000 without overflow. |
| Invalid inputs | POST missing values, zero, negative values, fractional numbers, or a value over the stated limits to each endpoint. Separately send a numeric string to the Next endpoint. | Invalid quantities receive a 400 response. The Next route also rejects numeric strings, keeping its browser-facing contract strict. |
| Oversized request | Send a body exceeding the configured limit to the Custom API and the Next route. | The Custom API enforces its request-byte limit; the Next handler returns 413 when the received text exceeds 1,024 characters. The hosting proxy can reject the body earlier. |
| Unavailable or inconsistent API | Exercise a timeout, upstream error, and an invalid arithmetic result using the isolated test host. | The Next route returns 502 and no cached or partial plan. The form shows an error and allows retry. |
| Input changes | Calculate a plan, then edit either field. Submit again and inspect the pending state. | The old result clears on edit, and both fields are disabled only while the new request is pending. |
| Keyboard and screen reader | Tab through both labelled inputs and the calculate button, then submit valid and invalid requests. | Controls are reachable, labels name the inputs, and result or error text is announced through the status region. |
| Gateway route | After the environment synchronizes CustomOcelot.json, POST the same 25/12 request through its gateway URL. | The request reaches the Custom API route and produces the same result under the gateway's configured access policy. |
Troubleshooting
Custom API returns 404
Confirm the controller file is in Custom.Api.Core, the application part is registered before the host builds, app.MapControllers remains present, and the request is POST /GcgCartonPlan/Calculate.
The controller cannot resolve CartonPlanner
Add the singleton registration before the existing service-provider creation and builder.Build(). Deploy the rebuilt host and Custom.Api.Core together.
Direct API works but the gateway fails
Check the route is appended inside the existing Routes array, both keys are customapi, POST is allowed, and the gateway has synchronized the deployed file. Then verify the gateway's access policy for this public route.
Next returns 502
Check GCG_CARTON_API_BASE_URL in the running server process, HTTPS reachability, the exact root origin, the five-second response time, and upstream JSON arithmetic. The handler deliberately does not expose the upstream exception to the browser.
HTTP localhost works only in development
That is the explicit origin policy. Use a trusted HTTPS API origin in production. Do not disable the protocol check to make a hosted environment work.
The page redirects to sign-in or cannot resolve a Store
Use the configured Store hostname and an enabled locale, then follow its normal sign-in policy. The existing locale and Store middleware still applies to this new route.
The page shows a result for the wrong quantities
Confirm the deployed form clears the result on input edits and disables its fieldset while pending. Check that the Next response arithmetic matches the submitted request and no intermediate proxy caches the POST result.
Requests fail after setting the environment file
Restart the actual WebStore process so it loads the setting. For a hosted deployment, configure the server environment rather than shipping an untracked local environment file.
References and further reading
Original examples and independent guidance from GCG. The sample data is fictional. Znode and Microsoft are trademarks of their respective owners. Discuss your implementation with GCG.
