i99dash docs
Recipes

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 install

The 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) =>
      ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' })[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.
  • getContext and fetch are both wrapped, but for different reasons. getContext throws on a real environment problem (no host, malformed schema). fetch rejects on a network failure or a CSP block — a thrown TypeError covering "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 dev

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

  1. 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.
  2. Declare its origin in manifest.network (step 2). One bare https://host[:port] per origin, max 10.
  3. 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. network is 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 network list 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.ok and try/catch, like any web app.

Where to go next

On this page