Fetch and render a list
The simplest real recipe — call your backend once, render a list, handle the failure path. ~10 minutes.
This is the recipe most developers want first: call a backend, get JSON back, render it. We'll build a tiny "fuel stations near me" list. ~10 minutes if you've done the Quickstart.
The finished widget:
┌──────────────────────────────────────┐
│ Fuel near you │
│ ──────────────────────────────── │
│ Aramco — Olaya Rd 2.33 SAR │
│ Shell — King Fahd Rd 2.34 SAR │
│ ADNOC — Tahlia St 2.35 SAR │
│ │
│ 3 stations · updated just now │
└──────────────────────────────────────┘What you'll exercise:
MiniAppClient.fromWindow()— the client.client.callApi()— backend proxy.- The success / error envelope branching.
- Loading + empty + error states.
- A fixture file so the dev-server has something to return.
Prerequisites
- Node 20 +, pnpm 9 +.
- ~10 minutes.
Before you start: where does the backend live?
The most-asked question on this recipe. In the call
client.callApi({ path: '/api/v1/fuel-stations', method: 'GET' });/api/v1/fuel-stations is not a URL — it's a route key the
host maps to one of two things, depending on environment:
| Where you run | What backs /api/v1/fuel-stations |
|---|---|
Local dev (pnpm dev) | A JSON fixture in mocks/*.json. No network call. |
| Production | Your own HTTPS service, mapped path-by-path in the host's allow-list. Configured by i99dash ops at deploy time. |
There is no shared platform backend for mini-apps to call for
free — the i99dash platform's own API is internal infrastructure,
not a developer surface. Every production callApi path resolves to
a service you own and run. (For dev, the mocks/ fixture is the
backend, so you can finish this recipe without standing anything
up.)
The wiring for your real backend is at the bottom of this page; it's a one-time coordination step with ops.
1 — Scaffold
pnpm dlx @i99dash/sdk-cli init fuel-list --template vanilla
cd fuel-list
pnpm installThe vanilla template gives you src/index.html, src/main.ts,
manifest.json, and an example fixture in mocks/.
2 — Replace src/main.ts
import { MiniAppClient, type MiniAppContext } from '@i99dash/sdk';
type Station = { name: string; road: string; price_sar: number };
const root = document.querySelector<HTMLElement>('#root')!;
const client = MiniAppClient.fromWindow();
void render();
async function render() {
// 1 — show a loading state up front so the user sees something.
root.innerHTML = '<p class="loading">Loading stations…</p>';
// 2 — pull context once. Used for the "near you" wording + the
// direction flip on Arabic locale.
let ctx: MiniAppContext;
try {
ctx = await client.getContext();
} catch (e) {
return renderError(`Couldn't read your context. ${describe(e)}`);
}
document.documentElement.lang = ctx.locale;
document.documentElement.dir = ctx.locale === 'ar' ? 'rtl' : 'ltr';
// 3 — fetch the stations. Note the envelope branch.
const r = await client.callApi<{ stations: Station[] }>({
path: '/api/v1/fuel-stations',
method: 'GET',
query: { vin: ctx.activeCarId },
});
if (!r.success) {
return renderError(
r.error.code === 'disallowed_path'
? `Backend not on the allow-list (${r.error.code}). Check ops.`
: r.error.message,
);
}
// 4 — empty state vs list.
if (r.data.stations.length === 0) {
root.innerHTML = '<p class="empty">No stations near this VIN.</p>';
return;
}
root.innerHTML = `
<h1>${ctx.locale === 'ar' ? 'الوقود قريب منك' : 'Fuel near you'}</h1>
<ul class="stations">
${r.data.stations
.map(
(s) => `
<li>
<span class="name">${escape(s.name)}</span>
<span class="road">${escape(s.road)}</span>
<span class="price">${s.price_sar.toFixed(2)} SAR</span>
</li>`,
)
.join('')}
</ul>
<footer>${r.data.stations.length} stations · updated just now</footer>
`;
}
function renderError(msg: string) {
root.innerHTML = `<p class="error">${escape(msg)}</p>`;
}
function describe(e: unknown): string {
return e instanceof Error ? e.message : String(e);
}
function escape(s: string): string {
return s.replace(
/[&<>"']/g,
(c) =>
({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[c]!,
);
}A few things worth noticing:
- Loading first.
render()paints a loading state synchronously before any async work. The user never sees an empty page. getContextis wrapped in try/catch, butcallApiis not. That's deliberate:getContextonly throws on real environment problems (no host, malformed schema).callApireturns its failures as data, so we branch onr.success.disallowed_pathgets a specific message. It's a config bug, not a user bug — distinguish in the UI so support tickets are actionable.- No
try/catcharoundescape(), but we do call it on every string we interpolate. Bundles are public; XSS via a backend that returned<script>is a plausible threat.
3 — Add the fixture
mocks/fuel-stations.GET.json:
{
"match": { "path": "/api/v1/fuel-stations", "method": "GET" },
"response": {
"success": true,
"data": {
"stations": [
{ "name": "Aramco", "road": "Olaya Rd", "price_sar": 2.33 },
{ "name": "Shell", "road": "King Fahd Rd","price_sar": 2.34 },
{ "name": "ADNOC", "road": "Tahlia St", "price_sar": 2.35 }
]
}
}
}The dev-server matches by path and method. First match wins; this
file alone is enough for the happy path.
4 — Add the failure fixture (optional but recommended)
To test your error UI without modifying code, add a query-gated fixture:
mocks/fuel-stations.disallowed.GET.json:
{
"match": {
"path": "/api/v1/fuel-stations",
"method": "GET",
"query": { "simulate": "disallowed" }
},
"response": {
"success": false,
"error": { "code": "disallowed_path", "message": "not on allow-list" }
}
}To trigger it: open the dev URL with ?simulate=disallowed appended,
or pass query: { simulate: 'disallowed' } from your code.
The dev-server picks the first fixture whose match is satisfied —
sort order is alphabetical, so a more specific filename (fuel-stations.disallowed...)
beats the generic one only when the query matches.
5 — Add minimal CSS
src/styles.css:
:root { color-scheme: light dark; }
body { font-family: system-ui; margin: 0; padding: 16px; }
.loading, .empty, .error { color: #888; }
.error { color: #c44; }
.stations { list-style: none; padding: 0; }
.stations li {
display: flex; gap: 12px; padding: 12px 0;
border-bottom: 1px solid color-mix(in srgb, currentColor 10%, transparent);
}
.name { font-weight: 600; }
.road { color: #888; flex: 1; }
.price { font-variant-numeric: tabular-nums; }
footer { margin-top: 16px; color: #888; font-size: 14px; }Reference it from src/index.html:
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<link rel="stylesheet" href="./styles.css" />
</head>
<body>
<div id="root"></div>
<script type="module" src="./main.ts"></script>
</body>
</html>6 — Run it
pnpm devOpen http://127.0.0.1:5173 — three stations render. Hit
/_sdk/ui and switch the locale to ar — the <html dir> flips
to rtl and the heading changes.
To exercise the error path: visit
http://127.0.0.1:5173?simulate=disallowed and confirm the error
message ends up where you expect.
Going to production with your own backend
The fixture is enough to ship the bundle — pnpm publish doesn't
care about the backend. But for the published app to actually return
real data on a user's car, the host needs to know where to forward
each path. Here's the wiring.
Step 1 — stand up the service
Anything that speaks HTTPS works. The contract you have to honour is
the CallApiResponse<T> envelope:
// success
{ "success": true, "data": <your payload> }
// failure
{ "success": false, "error": { "code": "<machine code>", "message": "<human>" } }A minimal Express/Fastify/Hono service that returns the same shape the fixture does:
// server.ts (Hono on Bun / Node)
import { Hono } from 'hono';
const app = new Hono();
app.get('/api/v1/fuel-stations', (c) => {
const vin = c.req.query('vin');
return c.json({
success: true,
data: {
stations: [
{ name: 'Aramco', road: 'Olaya Rd', price_sar: 2.33 },
{ name: 'Shell', road: 'King Fahd Rd', price_sar: 2.34 },
{ name: 'ADNOC', road: 'Tahlia St', price_sar: 2.35 },
],
},
});
});
export default app;Deploy on whatever you like (DO App Platform, Vercel, Fly, Cloudflare
Workers). Get an HTTPS URL like https://fuel.example.com.
Step 2 — declare the path in your manifest
Your manifest.json doesn't list the URL — it lists the path
prefix your app needs:
{
"id": "fuel-list",
"name": { "en": "Fuel List" },
"url": "https://your-cdn.example.com/fuel-list/",
"version": "0.1.0",
"category": "info",
"permissions": [
"callApi:/api/v1/fuel-stations"
]
}The host honours permissions declarations at install time — users
see "this app wants to call /api/v1/fuel-stations" in the consent
sheet. Without the permission, the host returns
{ success: false, error: { code: 'disallowed_path' } } for that
path.
Step 3 — coordinate the routing
Send i99dash ops:
- The path prefix(es) you declared (
/api/v1/fuel-stations). - The HTTPS URL of your service (
https://fuel.example.com). - Auth: how should the host pass the user's identity? Two options:
- Bearer-token forwarding. The host sends a short-lived JWT in
Authorization: Bearer ...that your backend verifies via the i99dash JWKS endpoint. Default and recommended. - mTLS / shared secret. For internal/enterprise backends. Ops will provision the cert.
- Bearer-token forwarding. The host sends a short-lived JWT in
Ops adds an entry like:
# host's allow-list (managed centrally; not in your repo)
- pathPrefix: /api/v1/fuel-stations
upstream: https://fuel.example.com
forwardAuth: jwt
ownerApp: fuel-listAfter this lands in production config, your published mini-app
starts hitting the real backend on next launch. No app rebuild
needed — path resolution is host-side.
Step 4 — verify the wire
The host attaches a short-lived JWT for the user's session in the
Authorization header. Verify it on your backend before trusting
the user identity. Ops will give you the JWKS URL, issuer,
and audience values to use:
import { jwtVerify, createRemoteJWKSet } from 'jose';
// Values supplied by ops when they add your app to the routing config.
const JWKS_URL = process.env.I99DASH_JWKS_URL!;
const ISSUER = process.env.I99DASH_ISSUER!;
const AUDIENCE = 'fuel-list'; // your manifest id
const JWKS = createRemoteJWKSet(new URL(JWKS_URL));
app.get('/api/v1/fuel-stations', async (c) => {
const auth = c.req.header('Authorization');
if (!auth?.startsWith('Bearer ')) {
return c.json(
{ success: false, error: { code: 'unauthorized', message: 'missing bearer' } },
401,
);
}
const { payload } = await jwtVerify(auth.slice(7), JWKS, {
issuer: ISSUER,
audience: AUDIENCE,
});
// payload.sub = userId, payload.vin = activeCarId, payload.locale = ...
// ...
});Now when your mini-app's callApi hits the host, the host attaches a
JWT signed for this user's session in your specific app, and your
backend can trust the user identity without the mini-app needing to
manage tokens.
Step 5 — test the production path locally
You don't need to wait for the host to route prod traffic to your service. Two patterns:
- Continue using fixtures for unit/integration tests. Fast, deterministic, no network.
- Point the dev-server at your real backend by writing a fixture
that proxies — there's a
passthroughUrloption insdk.config.jsonfor that. See Local development.
Once the production deployment is wired (step 3), publish your mini-app and traffic flows to your service automatically.
What you didn't have to do
- Auth. The host injects the user's session token; you don't manage credentials.
- CORS. The host is the network egress; CORS is its problem.
- Origin handling. Your
pathis host-relative — the allow-list decides where it resolves. - Schema validation of the envelope.
callApivalidates{ success, data | error }shape via zod before returning. A malformed payload throwsInvalidResponseError, which you can treat as "host bug" and report.
Where to go next
- Calling your backend — the
full mental model for
callApi. - Real-time car status widget — same shape, but with streaming subscriptions.
- Best practices — production patterns for the failure paths you just wired up.
- Testing — how to put this in CI without the dev-server.