By the end of this you will have a working storefront: a catalog rendered from your own products, a cart, a promo code that the server validates, and a total you can charge. Every step is real code you can paste.
The library is @appmint/js-client. It is small on purpose — transport, authentication and typed calls, nothing stateful — and it runs in two places with different behaviour in each, which is the part worth understanding before anything else.
Before you start
| You need | Where it comes from |
|---|---|
ORG_ID | Your organization id, in Studio Manager |
APP_ID, APP_KEY, APP_SECRET | Application credentials for that org |
| Node 18+ | fetch is used natively |
A catalog with at least one product makes step 4 more interesting, but the code works either way.
1. Install
npm install @appmint/js-client# .env.local — never commit this
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-secretAdd .env.local to .gitignore now, before you forget. APP_SECRET is the one credential that actually matters.
2. Create the client
// lib/appmint.ts
import { createClient } from '@appmint/js-client';
export const appmint = createClient({
baseUrl: process.env.APPENGINE_ENDPOINT!,
orgId: process.env.ORG_ID!,
appId: process.env.APP_ID,
appKey: process.env.APP_KEY,
appSecret: process.env.APP_SECRET,
});One module, imported from both server and browser code. What it does depends on where it runs:
Browser code → /api/… (your app) → appengine
Server code → appengine directlyOn the server it fetches an application token from profile/app/key, caches it for the process, and attaches it as Authorization: Bearer. In the browser it does none of that — it calls your own origin and sends only the visitor's session.
This is the security model, and it is structural rather than advisory. Browser code cannot reach appengine through this client, so APP_SECRET cannot leak by forgetting a flag. The next step is what makes the browser half work.
3. Stand up the proxy
The browser transport posts to /api/<path>. Something has to answer. In Next.js that is one catch-all route:
// app/api/[...slug]/route.ts
import { NextRequest, NextResponse } from 'next/server';
import { appmint } from '@/lib/appmint';
import { AppmintError } from '@appmint/js-client';
// A public catch-all must not expose the whole API. Only the surface a visitor
// legitimately needs is forwarded.
const ALLOWED = ['storefront/', 'profile/customer/', 'profile/magic-link', 'shipping/', 'client/', 'client-data/files/', 'affiliate/public/'];
function handler(method: string) {
return async (req: NextRequest, ctx: { params: Promise<{ slug: string[] }> }) => {
const { slug } = await ctx.params;
const path = slug.join('/');
if (!ALLOWED.some((p) => path.startsWith(p))) {
return NextResponse.json({ error: `Not proxied: ${path}` }, { status: 404 });
}
const search = new URL(req.url).search;
const body = method === 'GET' || method === 'DELETE' ? undefined : await req.json().catch(() => undefined);
// The visitor's own token, kept in an httpOnly cookie so page script
// cannot read it.
appmint.setCustomerToken(req.cookies.get('appmint_token')?.value ?? null);
try {
return NextResponse.json(await appmint.request(method, `${path}${search}`, body));
} catch (err) {
// Forward what appengine said. Collapsing everything into a 500 turns a
// missing record into an outage and hides validation messages.
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');
export const PUT = handler('PUT');
export const PATCH = handler('PATCH');
export const DELETE = handler('DELETE');Two details that are easy to get wrong:
The allow-list is not decoration. Without it, /api/repository/find/customer would happily return your customer table to anybody who typed it. The proxy moves credentials off the browser; it does not decide what a visitor may do.
Forward the upstream status. A 404 that arrives as a 500 looks like an outage. A 422 that arrives as a 500 hides the message the user needed to read.
4. Render the catalog
Fetch on the server — one round trip, rendered HTML, no API surface named in the browser:
// app/page.tsx
import { appmint } from '@/lib/appmint';
export const dynamic = 'force-dynamic';
export default async function CatalogPage() {
const page = await appmint.products.list({ ps: 24 });
return (
<ul>
{page.data.map((row) => {
const p = row.data;
// `price` is the list price. `calculatedPrice.finalPrice` is what THIS
// customer pays after price lists and rules — show that one.
const price = p.calculatedPrice?.finalPrice ?? p.price;
return <li key={row.sk}>{p.title || p.name} — {price}</li>;
})}
</ul>
);
}Showing p.price instead of calculatedPrice.finalPrice is the most common first bug. It looks right in a demo org where they are equal, and is wrong for every customer on a price list.
The response carries paging metadata beside the rows:
{ "total": 90, "page": 1, "pageSize": 24, "hasNext": true, "data": [ /* … */ ] }5. Price the cart
This is the step that matters most, so it gets the most words.
Keep in the browser only what the visitor chose — sku, name, quantity. Not money:
// lib/cart-storage.ts
export interface CartLine { sku: string; name: string; quantity: number }
const KEY = 'cart';
export const readCart = (): CartLine[] => JSON.parse(localStorage.getItem(KEY) ?? '[]');
export const writeCart = (lines: CartLine[]) => localStorage.setItem(KEY, JSON.stringify(lines));Then ask the server what it costs:
'use client';
import { appmint } from '@/lib/appmint';
import type { CartSummary } from '@appmint/js-client';
const summary: CartSummary = await appmint.cart.price({
productItems: readCart().map((l) => ({
sku: l.sku, name: l.name, quantity: l.quantity, itemType: 'product',
})),
shippingAddress: { street1: '1 Main St', city: 'Dallas', state: 'TX', zip: '75001', country: 'US' },
couponCode: coupon || undefined,
});A real response:
{
"subtotal": 900,
"discount": 0,
"tax": 74.25,
"productShipping": 75,
"total": 1049.25,
"shippingMethod": "flat",
"freeShipping": false,
"valid": true
}Render total as it arrived:
<tr><td>Subtotal</td><td>{summary.subtotal}</td></tr>
<tr><td>Shipping</td><td>{summary.freeShipping ? 'Free' : summary.productShipping}</td></tr>
<tr><td>Tax</td><td>{summary.tax}</td></tr>
<tr><td>Total</td><td>{summary.total}</td></tr>Why the coupon and address travel with the items
They go in the same call because the server nets the discount, resolves shipping against that address and computes tax on the result — in one pass. Fetch them separately and you get a summary that disagrees with itself: a shipping figure quoted before the discount that made the order qualify for free delivery.
Why there is no getTotal() helper
There deliberately is not one. total already includes tax and shipping:
900 (subtotal) + 75 (shipping) + 74.25 (tax) = 1049.25 (total)A client that computes subtotal + shipping - discount gets 975 and silently drops $74.25 of tax. That bug is hard to catch because it agrees on tax-free destinations — ship to a state with no sales tax and both numbers are 975. It diverges only where tax applies, which is usually production.
The same cart is priced for your website, your mobile app and your point-of-sale. Any arithmetic repeated in a client is a second opinion, and the day a price rule resolves differently server-side, that client quietly shows a number nobody will honour.
When pricing fails, show nothing
try {
setSummary(await appmint.cart.price({ /* … */ }));
} catch {
// No total is the honest answer. A locally summed stand-in looks right and
// disagrees with the invoice.
setSummary(null);
}6. Handle a rejected coupon
A bad code is not an exception. It comes back 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>{summary.message} The total is unchanged.</p>}Check the field, not the status. Several outcomes work this way — see Errors.
7. Shipping options
cart.price() returns one shipping figure. To let the customer choose:
const result = await appmint.shipping.options({
items: readCart().map((l) => ({ sku: l.sku, quantity: l.quantity })),
toAddress: address,
orderTotal: summary.subtotal,
});{ "served": true, "options": [ { "label": "Standard", "amount": 6, "currency": "USD" } ], "currency": "USD" }served: false means no configuration ships to that address — an honest refusal, never a guessed rate. served: true with an empty options is different: somewhere is covered but could not be priced right now, and problems[] says why — a carrier that could not be reached, a missing origin, a product with no parcel dimensions. Show that text; "we don't ship there" and "this product has no dimensions set" need different people to do different things.
8. Sign a customer in
const res = await appmint.auth.signIn(email, password);
// The client now carries the visitor on subsequent calls.On your server, put res.token in an httpOnly cookie. The proxy in step 3 reads it back and forwards it as x-client-authorization.
Passwordless works too:
await appmint.auth.magicLink(email); // the server emails a linkTwo identities, two headers. Authorization: Bearer is the application; x-client-authorization is the person. The client handles both, and you never name either one. Full detail in Authentication.
9. Errors
import { AppmintError } from '@appmint/js-client';
try {
await appmint.orders.mine();
} catch (err) {
if (err instanceof AppmintError) {
if (err.code === 'missing_authorization_header') return redirectToSignIn();
if (err.status === 429) return backOff();
console.error(err.code, err.message);
}
}Branch on code — it is stable. message is written for a person and will be reworded.
The full API
products | list, get, categories, brands |
cart | price |
shipping | options |
orders | checkout, get, mine, paymentGateways |
auth | signIn, signUp, magicLink, profile |
affiliate | resolve, trackClick, me |
Anything without a helper:
await appmint.get('storefront/collections');
await appmint.post('some/new/endpoint', { … });Where to go next
Working code for all of this is in the example apps — see Example apps for how to clone and run them.
Building a mobile app rather than a storefront? Build a Flutter app is the same walk on the Flutter client — the two speak the same contract.