docs
/
Client Integration

Build a web store

Build a working storefront on AppEngine from an empty folder — catalog, cart, promo codes and a total you can charge, explained decision by decision.

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:4100

Otherwise, start from nothing. It is about twenty minutes.

What you need

Node 18+fetch is used natively
An organization idORG_ID, from Studio Manager
App credentialsAPP_ID, APP_KEY, APP_SECRET
A product or twoThe 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/node

package.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.local

Write 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 directly

The 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, DELETE

Two 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 dev

http://localhost:4100

Work through these — each one demonstrates a decision from above:

TryWhat it shows
Catalog → product → add to cartServer-rendered catalog, finalPrice on display
Open the cartEvery figure came from calculate-cart
Enter a code that does not existTotal does not move; a message explains
Stop AppEngine, reload the cartThe total disappears rather than being invented
curl localhost:4100/api/repository/find/customer404 Not proxied
Search the bundle for your APP_SECRETNot there. It cannot be.

Where to take it

  • Checkout — storefront/checkout-cart places the order. Payment is configured per organization, so wire the gateway yours uses.
  • Sign-in — profile/customer/signin, or passwordless via profile/magic-link. Put the token in an httpOnly cookie; the proxy already reads appmint_token and forwards it.
  • Shipping choices — shipping/options returns every way the cart can ship, cheapest first. served: false means 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.