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:4200This 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 ──▶ AppEngineA 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-pathsThen 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-secretStep 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.
| Endpoint | Auth | What it does |
|---|---|---|
GET crm/reservations/definitions | public | What can be booked here |
POST crm/reservations/slots | public | What is free on a given day |
POST crm/reservations/create | app token | Take a slot |
GET crm/reservations/by-email/:email/:number? | public | Find a booking |
DELETE crm/reservations/cancel/:email/:number | app token | Release 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.
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 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| Route | What to try |
|---|---|
http://localhost:4200/ | The catalog, server-rendered |
/product/<sku> | A product page |
/cart | Add something, then apply a bad coupon and watch the total hold |
/book | The bookable services |
/book/<id>?date=2026-09-16 | Availability; 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.