We are going to build a storefront. By the end you will have a catalog of your own products, a cart, a promo code the server validates, and a total you can charge — running on your machine.
Every file in this tutorial is a file in nextjs-store. If you get stuck, clone that and compare. If you would rather read the finished thing first:
git clone https://github.com/JacLight/appmint-examples.git
cd appmint-examples/nextjs-store && npm install
cp .env.example .env.local # fill it in
npm run dev # http://localhost:4100Otherwise, start from nothing. It is about twenty minutes.
What you need
| Node 18+ | fetch is used natively |
| An organization id | ORG_ID, from Studio Manager |
| App credentials | APP_ID, APP_KEY, APP_SECRET |
| A product or two | The catalog step is dull without them |
Step 1 — An empty Next.js app
mkdir nextjs-store && cd nextjs-store
npm init -y
npm install next react react-dom
npm install -D typescript @types/react @types/nodepackage.json — note the port, so this never fights whatever else you have running:
{
"scripts": {
"dev": "next dev -p 4100",
"build": "next build",
"start": "next start -p 4100"
}
}A tsconfig.json with "paths": { "@/*": ["./*"] } so imports read as @/lib/appmint.
Step 2 — Credentials, and where they live
# .env.local
APPENGINE_ENDPOINT=https://appengine.appmint.io
ORG_ID=your-org-id
APP_ID=your-app-id
APP_KEY=your-app-key
APP_SECRET=your-app-secret# .gitignore
node_modules/
.next/
.env.localWrite the .gitignore now. APP_SECRET is the credential that matters, and the commit that leaks it is always the one made before the ignore file existed.
Note what is missing: no NEXT_PUBLIC_ prefix on anything. In Next.js that prefix is what ships a variable to the browser. None of these ever should, and the next two steps are how we guarantee it rather than remember it.
Step 3 — The client, and the one idea in this tutorial
Create lib/appmint.ts. This is the piece everything else rests on.
The problem: the catalog is fetched on the server, but the cart is priced from the browser after someone clicks. Same API, two very different places. If we write one client that talks to AppEngine, the browser version needs credentials — and a credential in browser JavaScript is published.
So the client does something different depending on where it is running:
const isBrowser = () => typeof window !== 'undefined';
/** Same call from either side — the transport is chosen for you. */
export function request<T = unknown>(method: string, path: string, body?: unknown): Promise<T> {
return isBrowser() ? viaProxy<T>(method, path, body) : viaServer<T>(method, path, body);
}Browser code → /api/… (your own app) → AppEngine
Server code → AppEngine directlyThe browser half carries no credentials at all:
/** Browser → this app's own proxy. Carries the visitor's session cookie, nothing else. */
async function viaProxy<T>(method: string, path: string, body?: unknown): Promise<T> {
const res = await fetch(`/api/${path.replace(/^\/+/, '')}`, {
method,
headers: body === undefined ? {} : { 'Content-Type': 'application/json' },
body: body === undefined ? undefined : JSON.stringify(body),
credentials: 'same-origin',
});
const payload = await readBody(res);
if (!res.ok) fail(payload, res);
return payload as T;
}The server half authenticates as the application:
export async function viaServer<T>(
method: string, path: string, body?: unknown, customerToken?: string, retried = false,
): Promise<T> {
const token = await getAppToken();
const headers: Record<string, string> = { orgid: ORG_ID };
if (body !== undefined) headers['Content-Type'] = 'application/json';
if (token) headers.Authorization = `Bearer ${token}`;
// The header called Authorization is the APPLICATION's. The person's token
// rides separately — they answer different questions.
if (customerToken) headers['x-client-authorization'] = customerToken;
const res = await fetch(`${HOST}/${path.replace(/^\/+/, '')}`, {
method, headers,
body: body === undefined ? undefined : JSON.stringify(body),
cache: 'no-store',
});
// A 401 is ambiguous — the app token may simply have aged out. Renew it once
// and try again before concluding anything about the visitor.
if (res.status === 401 && !retried) {
appToken = null;
return viaServer<T>(method, path, body, customerToken, true);
}
const payload = await readBody(res);
if (!res.ok) fail(payload, res);
return payload as T;
}Why this matters more than it looks. You cannot forget to protect a route. Browser code physically cannot reach AppEngine through this client — there is no code path from viaProxy to HOST. Security that depends on nobody making a mistake eventually meets somebody making a mistake.
The app token is fetched once per process from profile/app/key and cached. Concurrent callers share one in-flight fetch rather than all storming it on a cold start.
Step 4 — The proxy the browser talks to
viaProxy posts to /api/<path>. Something has to answer. Create app/api/[...slug]/route.ts:
const ALLOWED = [
'storefront/', 'profile/customer/', 'profile/magic-link',
'affiliate/public/', 'client/', 'client-data/files/', 'shipping/', 'notice/',
];
function handler(method: string) {
return async (request: NextRequest, ctx: { params: Promise<{ slug: string[] }> }) => {
const { slug } = await ctx.params;
const path = slug.join('/');
// A public catch-all must not expose the whole API.
if (!ALLOWED.some((prefix) => path.startsWith(prefix))) {
return NextResponse.json({ error: `Not proxied: ${path}` }, { status: 404 });
}
const search = new URL(request.url).search;
let body: unknown;
if (method !== 'GET' && method !== 'DELETE') {
body = await request.json().catch(() => undefined);
}
// The visitor's own token, if signed in. httpOnly, so page script cannot read it.
const customerToken = request.cookies.get('appmint_token')?.value;
try {
const result = await viaServer(method, `${path}${search}`, body, customerToken);
return NextResponse.json(result);
} catch (err) {
if (err instanceof AppmintError) {
return NextResponse.json({ error: err.message, code: err.code }, { status: err.status });
}
return NextResponse.json({ error: (err as Error).message }, { status: 500 });
}
};
}
export const GET = handler('GET');
export const POST = handler('POST');
// … PUT, PATCH, DELETETwo decisions worth dwelling on.
The allow-list is load-bearing. Without it this route forwards anything, and /api/repository/find/customer hands your customer table to whoever types it. The proxy moves credentials off the browser; it does not decide what a visitor may do. Those are different jobs and it only does the first.
Forward the upstream status. It is tempting to catch and return 500. Then a missing product reads as an outage, and a validation message the customer needed to see disappears. Pass through what AppEngine said.
Try it once the app runs:
curl localhost:4100/api/repository/find/customer
# {"error":"Not proxied: repository/find/customer"}Step 5 — The catalog
app/page.tsx, a server component:
export const dynamic = 'force-dynamic';
export default async function CatalogPage() {
const page = await viaServer<Paged<Product>>('GET', 'storefront/products?ps=24');
return (
<div className="grid">
{page.data.map((row) => {
const p = row.data;
const finalPrice = p.calculatedPrice?.finalPrice ?? p.price;
const original = p.calculatedPrice?.originalPrice;
const discounted = typeof original === 'number' && original > (finalPrice ?? 0);
return (
<Link key={row.sk} href={`/product/${encodeURIComponent(p.sku)}`} className="card">
<span className="name">{p.title || p.name}</span>
<span className="price">
{discounted && <span className="was">{money(original)}</span>}
{money(finalPrice)}
</span>
</Link>
);
})}
</div>
);
}Fetching on the server means one round trip and rendered HTML — no loading spinner, no API surface named in the bundle.
p.price is not what the customer pays. calculatedPrice.finalPrice is, after price lists and rules. The two are equal in a fresh demo org, so this bug looks fine while you build it and is wrong for every customer on a price list. Show finalPrice.
A row comes back as { sk, pk, datatype, data } — platform fields at the top, your product under data. The response also carries total, page, hasNext beside the rows, which is what you page with.
Step 6 — Product detail
app/product/[sku]/page.tsx is the same shape against storefront/product/<sku>, ending in a client component:
<AddToCart sku={product.sku} name={product.title || product.name} unitPrice={finalPrice} />Step 7 — A cart that holds no money
lib/cart-storage.ts:
export interface CartLine { sku: string; name: string; quantity: number; unitPrice: number }
const KEY = 'appmint.demo.cart';
export function readCart(): CartLine[] {
try {
const raw = window.localStorage.getItem(KEY);
return raw ? (JSON.parse(raw) as CartLine[]) : [];
} catch {
return []; // private browsing — the cart simply does not persist
}
}unitPrice is stored so a line can render while the server price is still loading. It is never summed. Not for a line total, not for a subtotal, not for anything.
That restraint is the whole point of the next step.
Step 8 — Ask the server what it costs
components/CartView.tsx:
const result = await post<CartSummary>('storefront/pricing/calculate-cart', {
productItems: current.map((l) => ({
sku: l.sku, name: l.name, price: l.unitPrice,
unitPrice: l.unitPrice, quantity: l.quantity, itemType: 'product',
})),
shippingAddress: ADDRESS,
couponCode: code || undefined,
});This is a browser call, so it goes through the proxy — no credentials, and you can watch it in the network tab.
A real response:
{
"subtotal": 900,
"discount": 0,
"tax": 74.25,
"productShipping": 75,
"total": 1049.25,
"shippingMethod": "flat",
"valid": true
}Render it field by field:
<tr><td>Subtotal</td><td>{money(summary.subtotal)}</td></tr>
<tr><td>Shipping</td><td>{summary.freeShipping ? 'Free' : money(summary.productShipping ?? 0)}</td></tr>
<tr><td>Tax</td><td>{money(summary.tax)}</td></tr>
<tr className="grand"><td>Total</td><td>{money(summary.total)}</td></tr>Why the coupon and the address go in the same call
The server nets the discount, resolves shipping against that address, then computes tax on the result — one pass. Ask separately and you get a summary that contradicts itself: shipping quoted before the discount that qualified the order for free delivery.
Why there is no getTotal() anywhere
total already includes tax and shipping:
900 (subtotal) + 75 (shipping) + 74.25 (tax) = 1049.25 (total)A client computing subtotal + shipping - discount gets 975 and silently loses $74.25 of tax. What makes this bug survive review is that it agrees on tax-free destinations — ship to a state with no sales tax and both are 975. It diverges only where tax applies, which is production.
The same cart is priced for this website, a mobile app and a till. Arithmetic repeated in a client is a second opinion.
Why failure shows nothing
} catch (e) {
// No usable price means no price shown. A locally summed stand-in would
// look right and disagree with the invoice.
setSummary(null);
setError((e as Error).message);
}A blank total is honest. A plausible wrong one is believed.
Step 9 — Promo codes
A bad code is not an exception. It returns 200 with a verdict, and the money is untouched:
{ "valid": false, "reason": "not_found", "message": "Discount code not found", "total": 1049.25 }{summary?.valid === false && (
<p className="note">{summary.message} The total is unchanged.</p>
)}Check the field, not the status. Several outcomes work this way — see Errors.
Step 10 — Run it
npm run devWork through these — each one demonstrates a decision from above:
| Try | What it shows |
|---|---|
| Catalog → product → add to cart | Server-rendered catalog, finalPrice on display |
| Open the cart | Every figure came from calculate-cart |
| Enter a code that does not exist | Total does not move; a message explains |
| Stop AppEngine, reload the cart | The total disappears rather than being invented |
curl localhost:4100/api/repository/find/customer | 404 Not proxied |
Search the bundle for your APP_SECRET | Not there. It cannot be. |
Where to take it
- Checkout —
storefront/checkout-cartplaces the order. Payment is configured per organization, so wire the gateway yours uses. - Sign-in —
profile/customer/signin, or passwordless viaprofile/magic-link. Put the token in an httpOnly cookie; the proxy already readsappmint_tokenand forwards it. - Shipping choices —
shipping/optionsreturns every way the cart can ship, cheapest first.served: falsemeans nothing ships there — say so, never guess a rate. - Anything else — Endpoints lists what a client can call.
Prefer a library over hand-rolled transport? TypeScript client is the same app built on @appmint/js-client.