docs
/
Example apps

Tutorial: the Remix storefront

Build a Remix store against AppEngine from an empty folder — catalog, product pages, a server-priced cart and a full reservation booking flow.

remix-store

Get the code

Browse it on GitHub · Download the repository as a zip

git clone https://github.com/JacLight/appmint-examples.git
cd appmint-examples/remix-store
cp .env.example .env.local     # fill in your org and app credentials
npm install
npm run dev                    # http://localhost:4200

This page builds that app from nothing, in order, explaining each decision. If you have already read the Next.js storefront, the interesting part is how much of it disappears here — and the reservation flow at the end, which the Next.js example does not have.


Why Remix changes the shape of the problem

The Next.js storefront spends real effort on one thing: keeping the application credentials off the browser. It has a client that behaves differently depending on typeof window, and a catch-all /api/* proxy with an allowlist, because in Next.js the same module can end up in either bundle and you have to be careful.

Remix removes both problems by construction.

Browser  ──▶  loader / action  ──▶  AppEngine

A loader and an action only run on the server. There is no version of them that ships to the browser. And a module named *.server.ts is a build error if client code imports it — not a runtime surprise you find in production.

So this app has no proxy and no dual-mode client. The browser asks Remix for a page; Remix asks AppEngine. That is the whole architecture.


Step 1 — the project

npm create remix@latest remix-store -- --template remix-run/remix/templates/remix
cd remix-store
npm i dotenv vite-tsconfig-paths

Then the one piece of Remix-specific plumbing that will otherwise waste an hour of your life. Next.js loads .env.local into process.env for you. Vite does not. Vite reads .env files for import.meta.env on the client; your server code reads process.env, and it will be empty.

vite.config.ts:

import { vitePlugin as remix } from '@remix-run/dev';
import { defineConfig } from 'vite';
import tsconfigPaths from 'vite-tsconfig-paths';
import dotenv from 'dotenv';

// Next.js loads `.env.local` into process.env for you. Vite does not — it
// reads .env files for client-side `import.meta.env`, but server code here
// reads process.env, so load it explicitly or every credential is undefined.
dotenv.config({ path: '.env.local' });

export default defineConfig({
  plugins: [remix({ future: { v3_singleFetch: true } }), tsconfigPaths()],
});

The symptom when you skip this is a catalog that renders an empty grid with no error, because the token fetch quietly failed with undefined credentials.

.env.local (git-ignored; .env.example is the tracked template):

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

Step 2 — the server client

One file, app/appmint.server.ts. The .server suffix is the point: this is the module that holds the secret, and Remix will refuse to let it reach the browser.

Two jobs — get an app token, and attach it to every call.

const HOST = process.env.APPENGINE_ENDPOINT ?? '';
const ORG_ID = process.env.ORG_ID ?? '';

let appToken: string | null = null;
let pending: Promise<string | null> | null = null;

async function fetchAppToken(): Promise<string | null> {
  const res = await fetch(`${HOST}/profile/app/key`, {
    method: 'POST',
    headers: { 'Content-Type': 'application/json', orgid: ORG_ID },
    body: JSON.stringify({
      appId: process.env.APP_ID,
      key: process.env.APP_KEY,
      secret: process.env.APP_SECRET,
    }),
  });
  if (!res.ok) return null;
  const body = await res.json().catch(() => null);
  return body?.token ?? null;
}

/** Concurrent loaders share one in-flight fetch rather than all storming it. */
async function getAppToken(): Promise<string | null> {
  if (appToken) return appToken;
  pending ??= fetchAppToken().finally(() => { pending = null; });
  appToken = await pending;
  return appToken;
}

That pending promise matters more than it looks. A page with three loaders starts them in parallel; without it, a cold process fires three identical token requests and races to see which one wins.

Now the call itself:

export async function appmint<T = unknown>(
  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';
  // `Authorization` is the APPLICATION's identity. The person's token rides
  // separately — they answer different questions.
  if (token) headers.Authorization = `Bearer ${token}`;
  if (customerToken) headers['x-client-authorization'] = customerToken;

  const res = await fetch(`${HOST}/${path.replace(/^\/+/, '')}`, {
    method, headers,
    body: body === undefined ? undefined : JSON.stringify(body),
  });

  // A 401 is ambiguous — the app token may simply have aged out. Renew once
  // before concluding anything about the visitor.
  if (res.status === 401 && !retried) {
    appToken = null;
    return appmint<T>(method, path, body, customerToken, true);
  }

  const text = await res.text();
  let payload: any;
  try { payload = text ? JSON.parse(text) : undefined; } catch { payload = text; }

  if (!res.ok) {
    throw new AppmintError(payload?.error ?? payload?.message ?? res.statusText, res.status, payload?.code);
  }
  return payload as T;
}

The two headers are the thing to internalise. Authorization: Bearer <appToken> says which application is asking. x-client-authorization says which person this is for. They are independent, and confusing them is the most common integration bug — see authentication.

The single retry on 401 is deliberate too. A 401 from AppEngine can mean "your app token expired" or "this visitor isn't allowed"; retrying once with a fresh app token separates the two without hiding the second.


Step 3 — the catalog

app/routes/_index.tsx. The loader runs on the server, so it just calls AppEngine:

export async function loader() {
  try {
    const page = await appmint<Paged<Product>>('GET', 'storefront/products?ps=24');
    return json({ page, error: null as string | null });
  } catch (e) {
    return json({ page: null, error: (e as Error).message });
  }
}

Reads come back paged — data holds the records, the rest describes the page. Each record is a stored model: platform fields at the top, your payload under .data, and sk as the id.

export interface Paged<T> {
  data: BaseModel<T>[];
  total: number; page: number; pageSize: number; hasNext: boolean;
}

Rendering has exactly one subtlety:

// `price` is the list price. `finalPrice` is what THIS customer pays.
const finalPrice = p.calculatedPrice?.finalPrice ?? p.price;
const original = p.calculatedPrice?.originalPrice;

price is the sticker. calculatedPrice.finalPrice is the number this visitor is actually charged after price lists, customer groups and promotions have been resolved by the server. Showing price on a discounted catalog is how you end up explaining to a customer why the cart disagrees with the grid.

Note the error branch: when AppEngine is unreachable the page says so and points at .env.local. An empty grid looks identical to "you have no products", and that is a bad half hour for whoever is trying your example.


Step 4 — the product page

app/routes/product.$sku.tsx is the same move with a parameter, and one new idea: a 404 is a thrown Response, not a rendered message.

export async function loader({ params }: LoaderFunctionArgs) {
  const row = await appmint<BaseModel<Product> | Product>(
    'GET', `storefront/product/${encodeURIComponent(params.sku!)}`,
  );
  // An unknown sku comes back as 200 with an empty body, not a 404 — so the
  // absence has to be detected here rather than trusted to the status code.
  const product = row ? ((row as BaseModel<Product>).data ?? (row as Product)) : null;
  if (!product) throw new Response('Not found', { status: 404 });
  return json({ product, error: null as string | null });
}

Two things are happening there.

storefront/product/:sku answers 200 with an empty body for a sku that does not exist. It is not an error as far as AppEngine is concerned — you asked, and the answer is nothing. So your route decides what nothing means.

And it decides by throwing a Response, which makes the page genuinely a 404 — to a crawler, to an uptime monitor, to anything that reads status codes. Rendering "not found" inside a 200 is a lie that search engines believe, and they will happily index the apology.

A failure to reach AppEngine is a different thing and must not become a 404:

} catch (e) {
  if (e instanceof Response) throw e;
  if (e instanceof AppmintError && e.status === 404) throw new Response('Not found', { status: 404 });
  // Anything else is our problem, not a missing product. Say so.
  return json({ product: null, error: (e as Error).message });
}

Step 5 — the cart, and the one rule that matters

The cart lines live in the browser (app/cart-storage.ts, localStorage), and that is fine: a list of SKUs and quantities is not a price.

Every figure with a currency symbol comes from the server. Not the subtotal, not the tax, not the total. This is the rule people break, so it is worth being precise about why.

storefront/pricing/calculate-cart returns:

{ "subtotal": 900, "discount": 0, "tax": 74.25, "productShipping": 75, "total": 1049.25 }

total already includes tax and shipping. The obvious-looking subtotal + shipping − discount gives 975 — it silently drops $74.25 of tax. And that bug survives review, because it produces the right answer for every tax-free destination you happen to test with.

There is no arithmetic you can do in a component that is safe here. Tax depends on the destination, on product tax classes and on rules you do not have. Render the fields, do not combine them.

The browser posts to a resource route rather than a proxy:

// app/routes/api.price.tsx
export async function action({ request }: ActionFunctionArgs) {
  const body = await request.json().catch(() => ({}));

  // The visitor's own token, if signed in.
  const cookie = request.headers.get('Cookie') ?? '';
  const customerToken = /appmint_token=([^;]+)/.exec(cookie)?.[1];

  const summary = await appmint<CartSummary>('POST', 'storefront/pricing/calculate-cart', {
    productItems: body.productItems ?? [],
    shippingAddress: body.shippingAddress,
    couponCode: body.couponCode || undefined,
  }, customerToken);
  return json(summary);
}

This is the Remix answer to the Next.js allowlist. That proxy needs a list of permitted prefixes because it can forward anything; this route prices a cart and is structurally incapable of doing anything else. Nothing to maintain and nothing to forget.

A rejected coupon is not an error — the server returns a valid total with valid: false and a reason. Show the message, keep the total:

{summary?.valid === false && (
  <p className="note">{summary.message ?? 'That code could not be used.'} The total is unchanged.</p>
)}

And when pricing fails outright, the app shows no price at all rather than a locally-summed stand-in. A wrong number that looks right is worse than a blank.


Step 6 — reservations

Now the part that is only in this example: booking appointments. Four endpoints, and they compose into a complete guest booking flow with no sign-in at all.

EndpointAuthWhat it does
GET crm/reservations/definitionspublicWhat can be booked here
POST crm/reservations/slotspublicWhat is free on a given day
POST crm/reservations/createapp tokenTake a slot
GET crm/reservations/by-email/:email/:number?publicFind a booking
DELETE crm/reservations/cancel/:email/:numberapp tokenRelease it

6a — what can be booked

app/routes/book._index.tsx:

export async function loader() {
  // Reads come back paged: `data` holds the records, the rest describes the
  // page. Reach for `.data`, not the envelope.
  const page = await appmint<Paged<ReservationDefinition>>('GET', 'crm/reservations/definitions');
  const active = (page?.data ?? []).filter((r) => r.data?.status !== 'inactive');
  return json({ definitions: active, error: null as string | null });
}

A reservation definition is the bookable thing: a consultation, a table, a screening. It carries its own working days, office hours, timezone, and optionally a list of services with durations and prices.

{
  name: 'chaq-time',
  title: 'Chaq Time',
  workDays: ['Monday','Tuesday','Wednesday','Thursday','Friday'],
  officeHours: { startTime: '09:00', endTime: '17:00', timezone: 'America/Chicago' },
  services: [{ name: 'Chaq Time', duration: 30, price: 50 }],
  paymentRequired: false,
}

services is optional. A definition without one is itself the single service, and the server falls back to the definition's own name and duration.

The id you need for every later call is the record's sk, not data.name.

6b — availability is an answer, not a calculation

It is tempting to look at officeHours and workDays and generate the time slots yourself. Don't. The server knows three things you cannot see:

  • blocked time — recurring windows the business is closed within its own hours
  • grace periods — the gap it wants between appointments
  • everyone else's bookings — including ones made one second ago

So ask:

export async function loader({ params, request }: LoaderFunctionArgs) {
  const url = new URL(request.url);
  const tz = definition.officeHours?.timezone || 'UTC';
  const date = url.searchParams.get('date') || todayAt(tz);

  const res = await appmint<SlotsResponse>('POST', 'crm/reservations/slots', {
    reservationDefinitionId: id,
    serviceDate: date,          // YYYY-MM-DD, in the BUSINESS's calendar
    serviceName,
  });
  return json({ slots: res.slots ?? [], ... });
}

The chosen day lives in the URL as a search param, which is the whole reason to use Remix here. Change the date and the loader re-runs; availability can never be stale state sitting in a component, and the URL is shareable.

The answer:

{
  "service": { "name": "Chaq Time", "duration": 30, "price": 50 },
  "slots": [
    { "startTime": "2026-09-16T14:00:00.000Z", "endTime": "2026-09-16T14:30:00.000Z",
      "spotsAvailable": 1, "businessTimezone": "America/Chicago" }
  ]
}

6c — timezones, done in one line

Every instant on the wire is UTC, and the zone it means something in arrives alongside it as businessTimezone. That pairing is the entire timezone strategy.

A visitor in Lagos booking a Chicago clinic must see the clinic's 9:00 AM. Not their 3:00 PM, and certainly not a number you produced with arithmetic.

export const atBusiness = (
  iso: string | undefined,
  timeZone: string | undefined,
  opts: Intl.DateTimeFormatOptions = { hour: 'numeric', minute: '2-digit' },
) =>
  iso
    ? new Intl.DateTimeFormat('en-US', { ...opts, timeZone: timeZone || 'UTC' }).format(new Date(iso))
    : '—';

Hand the zone to Intl and there is nothing left to get wrong. 14:00:00.000Z with America/Chicago renders as 9:00 AM, and it stays correct across the daylight-saving boundary that a stored offset would have broken.

The same idea gives you today's date in the business's calendar — which is what the slots API wants, and is not necessarily today where the visitor is:

export const todayAt = (timeZone = 'UTC') =>
  new Intl.DateTimeFormat('en-CA', { timeZone, year: 'numeric', month: '2-digit', day: '2-digit' })
    .format(new Date());

en-CA because it formats as YYYY-MM-DD. That is a trick, but a stable one.

6d — let the server say no

Ask for a Sunday at a business that is closed on Sundays and you get a 400 carrying a sentence written for a human:

We are closed on this day. Please select Tuesday,Monday,Wednesday,Thursday,Friday.

Catch it and render it. Do not pre-filter the date picker against workDays and then silently show nothing — the server's message is more useful than your empty state, and it stays correct when the business changes its hours.

try {
  const res = await appmint<SlotsResponse>('POST', 'crm/reservations/slots', { ... });
  slots = res.slots ?? [];
} catch (e) {
  // A closed day comes back as a 400 with a sentence meant for a human. Show it.
  slotError = (e as Error).message;
}

6e — booking, with no account

crm/reservations/create needs the app token, but not a customer token. Send contact details and AppEngine finds or creates the customer record itself, so the booking still belongs to somebody:

export async function action({ params, request }: ActionFunctionArgs) {
  const form = await request.formData();
  const email = String(form.get('email') || '').trim();

  const created = await appmint<{ name?: string }>('POST', 'crm/reservations/create', {
    reservationDefinitionId: params.id,
    service: form.get('service') || undefined,
    startTime: form.get('startTime'),
    endTime: form.get('endTime'),
    customer: {
      email,
      name: String(form.get('name') || '').trim(),
      phone: String(form.get('phone') || '').trim() || undefined,
    },
    note: String(form.get('note') || '').trim() || undefined,
  });

  const qs = new URLSearchParams({ email });
  if (created?.name) qs.set('number', created.name);
  return redirect(`/bookings?${qs}&booked=1`);
}

Two details worth copying:

The instants go back unchanged. They are hidden inputs holding exactly what the slots call offered:

<input type="hidden" name="startTime" value={slot.startTime} />
<input type="hidden" name="endTime" value={slot.endTime} />

Never re-derive the end time from a duration in the browser. The server already decided what this appointment is; echo it.

The reservation number is name on the created record — something like ZPLLVZTK. That, plus the email, is the guest's only handle on the booking. Redirect so it is on screen immediately.

The whole form is a plain <Form method="post">. No fetch, no submit handler, no loading state you maintain by hand — useNavigation() tells you when it is in flight.

6f — find it and cancel it

app/routes/bookings.tsx. Lookup is a loader driven by search params, so /[email protected] is a link you can put in a confirmation email:

const path = number
  ? `crm/reservations/by-email/${encodeURIComponent(email)}/${encodeURIComponent(number)}`
  : `crm/reservations/by-email/${encodeURIComponent(email)}`;
const page = await appmint<Paged<Reservation>>('GET', path);

Cancelling is an action keyed by the same two facts:

await appmint('DELETE', `crm/reservations/cancel/${encodeURIComponent(email)}/${encodeURIComponent(number)}`);
return redirect(`/bookings?email=${encodeURIComponent(email)}`);

Redirect rather than render a success message. The visitor then sees the actual state of their bookings, not a claim about it.

The email is the credential, and the match is loose

by-email is public, and AppEngine matches the address with a case-insensitive regex — a partial address matches too. That is the same trade every "manage your booking" link in your inbox makes, and it is fine for a restaurant. It is not fine for a clinic. If the bookings are sensitive, sign the customer in and pass their token as x-client-authorization; the endpoint honours it.

Cancel deletes the record

cancel removes the reservation rather than marking it cancelled, so a cancelled booking disappears from by-email instead of appearing struck through. The app still styles non-live statuses — declined, expired, no-show can be set by staff or a workflow and do come back — but do not expect to see your own cancellation listed.


Step 7 — run it

cd appmint-examples/remix-store
cp .env.example .env.local
npm install
npm run dev
RouteWhat to try
http://localhost:4200/The catalog, server-rendered
/product/<sku>A product page
/cartAdd something, then apply a bad coupon and watch the total hold
/bookThe bookable services
/book/<id>?date=2026-09-16Availability; try a closed day
/[email protected]Find and cancel

What to take from it

.server.ts is a guarantee, not a convention. Credentials live in a module the browser cannot import. That is stronger than remembering to check typeof window.

Resource routes beat a proxy. One route per capability, each able to do exactly one thing. No allowlist to keep in sync with your intentions.

Search params are your state. The booking date, the chosen slot and the lookup email are all in the URL, so every view is shareable, back works, and nothing is cached in a component that reality has moved past.

Ask, don't derive. Prices, totals, availability and timezones are all answers the server already has. Every one of them is something a client can compute plausibly and wrongly.