Fetch and render a list
The simplest real recipe — declare a network origin, fetch() JSON from it, render a list, handle the failure path. ~10 minutes.
This is the recipe most developers want first: call an API, 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 (for host context only).manifest.network— declaring the origin your app may reach.- Plain
fetch()against that origin. - Loading + empty + error states.
Prerequisites
- Node 20 +, pnpm 9 +.
- An HTTPS API that returns the data. We'll point at a public demo API for the recipe; swap in your own at the end.
- ~10 minutes.
How the network call works
There is no SDK proxy and no host routing table. A mini-app reaches an
external API the normal web way — fetch() — with one rule: the
origin must be declared in your manifest's network field. The car
host turns that list into a per-app Content-Security-Policy and blocks
every origin you didn't declare. See
Calling an external API for
the full model.
For this recipe we use a fictional https://api.fuel.example.com.
Replace it with a real origin you control (or a public API) when you
build for real.
1 — Scaffold
pnpm dlx i99dash init fuel-list --template vanilla
cd fuel-list
pnpm installThe vanilla template gives you src/index.html, src/main.ts, and
manifest.json.
2 — Declare the origin in manifest.json
Add the API's origin to network. It's a bare https://host[:port]
— no path, no query, no wildcard:
{
"id": "fuel-list",
"name": { "en": "Fuel List" },
"icon": "./icon.svg",
"url": "https://miniapps.i99dash.app/fuel-list/",
"version": "0.1.0",
"category": "services",
"network": ["https://api.fuel.example.com"]
}Omit network and every cross-origin fetch() is blocked by the
host's CSP. Forget an origin and the call fails only on-car (it'll
still work in a plain browser tab) — that mismatch is the #1 thing to
check when an API call works in dev but not on a real head unit.
3 — Replace src/main.ts
import { MiniAppClient, type MiniAppContext } from 'i99dash';
type Station = { name: string; road: string; price_sar: number };
const API = 'https://api.fuel.example.com';
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 host context once. Used for the "near you" wording + the
// direction flip on Arabic locale. getContext can throw on a real
// environment problem, so it's wrapped.
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 from the declared origin. Normal web fetch:
// branch on res.ok, catch network / CSP failures.
let stations: Station[];
try {
const res = await fetch(
`${API}/v1/stations?device_id=${encodeURIComponent(ctx.activeCarId)}`,
);
if (!res.ok) return renderError(`Service error (HTTP ${res.status}).`);
const body = (await res.json()) as { stations?: Station[] };
stations = body.stations ?? [];
} catch {
// Network down, DNS/TLS failure, OR a CSP block because the origin
// isn't in manifest.network → fetch() rejects with TypeError.
return renderError('No connection — check the network and try again.');
}
// 4 — empty state vs list.
if (stations.length === 0) {
root.innerHTML = '<p class="empty">No stations near this car.</p>';
return;
}
root.innerHTML = `
<h1>${ctx.locale === 'ar' ? 'الوقود قريب منك' : 'Fuel near you'}</h1>
<ul class="stations">
${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>${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. getContextandfetchare both wrapped, but for different reasons.getContextthrows on a real environment problem (no host, malformed schema).fetchrejects on a network failure or a CSP block — a thrownTypeErrorcovering "no connection" and "origin not declared" alike.- Defensive parse.
body.stations ?? []— a third-party response can omit fields. Never assume the shape; for anything load-bearing, validate with a schema (zod). escape()on every interpolated string. Bundles are public and the API is external; a response containing<script>is a plausible XSS vector.
4 — 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>5 — Run it
pnpm devOpen http://127.0.0.1:5173. In dev the CSP isn't enforced, so the
fetch() goes straight to the real API — point API at something that
returns the expected shape (your own service, a public demo endpoint,
or a quick local server). Hit /_sdk/ui and switch the locale to ar
— the <html dir> flips to rtl and the heading changes.
To exercise the error path, point API at a host that returns a 5xx
(or an unreachable one) and confirm the error message renders.
Bringing your own API
The recipe points at https://api.fuel.example.com. To ship for real:
- Stand up (or pick) an HTTPS API that returns the JSON your app expects. Anything that speaks HTTPS works — your own service on DO / Vercel / Fly / Cloudflare, or a public third-party API.
- Declare its origin in
manifest.network(step 2). One barehttps://host[:port]per origin, max 10. - Publish. No ops coordination, no routing config — the origin in your manifest is the wiring. On the next launch the host's CSP permits exactly the origins you declared.
Secrets and auth
Declared egress is unauthenticated — the host attaches no i99dash
credentials, and your bundle is public (unzip + strings extracts
anything you embed). So:
- Never put an API key in the bundle. If your upstream needs a
secret, stand up a thin HTTPS service of your own that holds the
secret server-side, declare that service's origin in
network, and let it call the upstream. - Treat declared origins as a public attack surface.
networkis a least-privilege control reviewed at publish, not a sandbox — see Calling an external API.
What you didn't have to do
- No SDK proxy.
fetch()is the API. The SDK is only there for host context (locale, active car), not for the network call. - No ops coordination. The
networklist in your manifest is the entire routing story. No host-side allow-list to file a ticket for. - No envelope. Responses are whatever your API returns. Branch on
res.okandtry/catch, like any web app.
Where to go next
- Calling an external API — the full mental model for declared egress.
- Call a third-party API — the minimal version of this recipe.
- Prayer times — offline-first local compute plus optional declared egress.
- Real-time car status widget — car data via streaming subscriptions instead of HTTP.
- Best practices — production patterns for the failure paths you just wired up.