DEV Community

Cover image for A news app without a backend, in one HTML file
APITube News API
APITube News API

Posted on

A news app without a backend, in one HTML file

A news app without a backend, in one HTML file

A news app without a backend is a single HTML file that calls a news API from the browser with fetch, renders the JSON, and is served by a static host such as GitHub Pages. No server of yours runs anywhere. Ours is 97 lines. The first headline lands 858 ms after page load, and a repeat visit in the same tab makes zero network requests because the response is cached in sessionStorage for 15 minutes.

The part that most "news app with vanilla JS" tutorials skip: the API key ships to the browser, where anyone can read it from the network tab. So this tutorial spends as much time on the key as on the DOM. There are three ways to handle it, and the right one depends on one number: how many requests per day you can afford. On a free plan that number is 100.

Everything here was measured on 16 September 2026 with curl and headless Chrome. The CSV files behind every number sit next to the article.

Disclosure: APITube is our product. We use it for the requests below because it answers browser calls from any origin; the file structure works with any JSON news API.

Takeaways

  • Asking for 7 fields instead of the full article object shrank a 10-headline response from 238 KB to 9.5 KB and cut the median request from 1.21 s to 0.85 s (15 runs each).
  • Sending the key as an X-API-Key header fails in Chrome today: the preflight reply carries Access-Control-Allow-Origin twice. The key in the query string works.
  • A 100-requests-per-day free plan is gone after 100 first visits if the browser calls the API directly.
  • A 35-line Cloudflare Worker with a 15-minute cache caps upstream calls at 96 per day, whatever the traffic. Cached responses came back in 3 ms locally, uncached in 656 ms.

Contents: Step 1: get a key you can afford to expose · Step 2: the request, and two things that break in the browser · Step 3: index.html · Step 4: deploy to GitHub Pages · Step 5: when the key must stay secret, a 35-line Worker · FAQ

Who this is for

This is for frontend beginners who know what fetch and querySelector do and want a deployable project, not a codepen. No build step, no npm, no framework. You need a GitHub account and an API key.

Step 1: get a key you can afford to expose

Sign up at apitube.io, open the dashboard, and decide which of these three keys you put in the file.

Key model When it fits What limits the damage
Test key (api_test_…) Demos, portfolio, learning Consumes no quota; article text comes back truncated; stricter rate limit
Live key with referrer and IP rules Real headlines, traffic under 100 requests/day Dashboard rules restrict the key to your domain and to one endpoint
Live key behind a Worker proxy Real headlines, more than 100 requests/day Key never leaves the Worker; the cache absorbs the traffic (Step 5)

The dashboard has a Test mode toggle at the bottom of the sidebar. Keys created there start with api_test_ and return the real response shape, so the file below works unchanged. For a live key, set the referrer rule to your GitHub Pages origin and the endpoint scope to /v1/news/everything before you commit anything. Referrer rules are not a lock: a browser sends the Referer header, curl sends whatever it likes. They stop drive-by abuse, not a determined person.

Step 2: the request, and two things that break in the browser

The request that powers the page, first in curl:

curl "https://api.apitube.io/v1/news/everything?language.code=en&is_duplicate=0&per_page=10&sort.by=published_at&sort.order=desc&fl=title,href,published_at,image,description,source.domain,source.favicon&api_key=YOUR_API_KEY"
Enter fullscreen mode Exit fullscreen mode

One item from the results array, exactly as returned:

{
  "title": "The AlleyWatch Startup Daily Funding Report: 9/16/2026",
  "href": "https://alleywatch.com/2026/09/the-alleywatch-startup-daily-funding-report-9-16-2026/",
  "published_at": "2026-09-16T16:52:55.000Z",
  "image": "https://alleywatch.com/wp-content/uploads/2019/11/nyc_tech_startup_funding_daily-report_9-16.jpg",
  "description": "The latest venture capital, seed, pre-seed, and angel deals for NYC startups for 9/16/2026 featuring funding details for Stuut, Evvy, Footprint, Tare, and much more. This page will be updated throughout the. . .",
  "source": {
    "domain": "alleywatch.com",
    "favicon": "https://www.google.com/s2/favicons?domain=https://alleywatch.com"
  }
}
Enter fullscreen mode Exit fullscreen mode

The fl parameter is the field list. Without it, each article arrives with body, body HTML, entities, sentiment, and 25 other keys the page never reads. We measured both variants 15 times each with curl:

Ten headlines weigh 238 KB with the full article object and 9.5 KB with seven fields; the median request drops from 1.21 s to 0.85 s

Variant Response size Median time p90 time
Full article objects, 10 items 238 KB 1.21 s 1.56 s
fl= with 7 fields, 10 items 9.5 KB 0.85 s 1.23 s

Two things bit us before the page rendered anything.

is_duplicate=false is a 400. The API wants 0 or 1 and answers ER0003 "Invalid is_duplicate value." otherwise. The page shows that code in its status line instead of a generic "something went wrong", because the error body is JSON with an errors[0].code and errors[0].message.

The key cannot go in a header. A custom header such as X-API-Key turns the request into a preflighted one, and the API's reply to the OPTIONS preflight currently carries Access-Control-Allow-Origin: * twice. Chrome refuses that with "contains multiple values '*, *'" and the fetch never happens. A plain GET with the key in the query string is a simple request, gets a single Access-Control-Allow-Origin: *, and works from any origin. That is what the file does. The same test with Authorization: Bearer fails the same way. We reported it; check the response headers before assuming it is still true when you read this.

One more limit: the API does not send Access-Control-Expose-Headers, so the page cannot read X-RateLimit-Remaining. If you want a counter, count your own requests.

Step 3: index.html

Replace YOUR_API_KEY, open the file, done. Ninety-seven lines including CSS.

<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Headlines</title>
<link rel="icon" href="data:,">
<style>
  body { margin: 0 auto; max-width: 720px; padding: 16px; font-family: system-ui, sans-serif; color: #111; background: #fafafa; }
  header { display: flex; gap: 8px; align-items: center; flex-wrap: wrap; }
  h1 { font-size: 1.4rem; margin: 0 auto 0 0; }
  input, button { font: inherit; padding: 8px 10px; border: 1px solid #bbb; border-radius: 6px; background: #fff; }
  button { cursor: pointer; }
  #status { color: #666; font-size: .9rem; margin: 10px 0; }
  article { display: grid; grid-template-columns: 96px 1fr; gap: 12px; padding: 12px 0; border-top: 1px solid #e5e5e5; }
  article img { width: 96px; height: 72px; object-fit: cover; border-radius: 6px; background: #eee; }
  article h2 { font-size: 1rem; margin: 0 0 4px; }
  article h2 a { color: #0254EC; text-decoration: none; }
  article p { margin: 0 0 6px; font-size: .9rem; color: #444; }
  article small { color: #777; display: flex; gap: 6px; align-items: center; }
  article small img { width: 14px; height: 14px; }
</style>
</head>
<body>
<header>
  <h1>Headlines</h1>
  <input id="q" placeholder="Search titles…" aria-label="Search titles">
  <button id="go">Search</button>
  <button id="refresh" title="Refresh"></button>
</header>
<p id="status">Loading…</p>
<main id="list"></main>
<script>
// Option A: a key you accept being public (a test key, or a live key with
// referrer rules). Option B: leave API_KEY empty and point PROXY_URL at a
// Worker that holds the real key.
const API_KEY = "YOUR_API_KEY";
const PROXY_URL = "";                 // e.g. "https://news-proxy.you.workers.dev"
const ENDPOINT = PROXY_URL || "https://api.apitube.io/v1/news/everything";
const CACHE_TTL_MS = 15 * 60 * 1000;  // free plan: 100 requests/day
const FIELDS = "title,href,published_at,image,description,source.domain,source.favicon";

async function loadNews(title = "") {
  const params = new URLSearchParams({
    "language.code": "en", is_duplicate: "0", per_page: "10",
    "sort.by": "published_at", "sort.order": "desc", fl: FIELDS,
  });
  if (title) params.set("title", title);
  // The key goes in the query string, not in an X-API-Key header: a custom
  // header forces a CORS preflight, and the API's preflight reply currently
  // sends Access-Control-Allow-Origin twice, which Chrome rejects.
  if (API_KEY) params.set("api_key", API_KEY);
  const url = `${ENDPOINT}?${params}`;
  const cached = JSON.parse(sessionStorage.getItem(url) || "null");
  if (cached && Date.now() - cached.t < CACHE_TTL_MS) return cached.data;

  const res = await fetch(url);
  if (!res.ok) {
    const err = (await res.json().catch(() => ({}))).errors?.[0];
    throw new Error(err ? `${res.status} ${err.code}: ${err.message}` : `HTTP ${res.status}`);
  }
  const data = await res.json();
  sessionStorage.setItem(url, JSON.stringify({ t: Date.now(), data }));
  return data;
}

const list = document.getElementById("list");
const status = document.getElementById("status");
const esc = (s) => String(s ?? "").replace(/[&<>"']/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" }[c]));
const ago = (iso) => { const m = Math.round((Date.now() - new Date(iso)) / 60000); return m < 60 ? `${m} min ago` : `${Math.round(m / 60)} h ago`; };

function render(data) {
  list.innerHTML = data.results.map((a) => `
    <article>
      ${a.image ? `<img src="${esc(a.image)}" alt="" loading="lazy" onerror="this.style.visibility='hidden'">` : "<div></div>"}
      <div>
        <h2><a href="${esc(a.href)}" target="_blank" rel="noopener">${esc(a.title)}</a></h2>
        <p>${esc(a.description)}</p>
        <small><img src="${esc(a.source.favicon)}" alt="">${esc(a.source.domain)} · ${ago(a.published_at)}</small>
      </div>
    </article>`).join("");
  status.textContent = `${data.results.length} stories · updated ${new Date().toLocaleTimeString()}`;
}

async function show(title) {
  status.textContent = "Loading…";
  try { render(await loadNews(title)); }
  catch (e) { status.textContent = e.message; list.innerHTML = ""; }
}

document.getElementById("go").onclick = () => show(document.getElementById("q").value.trim());
document.getElementById("q").onkeydown = (e) => { if (e.key === "Enter") show(e.target.value.trim()); };
document.getElementById("refresh").onclick = () => { sessionStorage.clear(); show(document.getElementById("q").value.trim()); };
show();
</script>
</body>
</html>
Enter fullscreen mode Exit fullscreen mode

The page in headless Chrome: ten headlines with thumbnails, source favicon and

Four decisions worth knowing about:

  1. Every value goes through esc(). Headlines are untrusted input. A title containing <img onerror=…> would otherwise run in your page.
  2. The cache key is the full URL. Search for "tesla" and you get a separate 15-minute bucket, so a second search for the same word costs nothing.
  3. Images fail silently. In our run 8 of 10 stories carried an image and 5 of those 8 loaded. The rest were blocked by the publisher's Cross-Origin-Resource-Policy header. onerror hides the grey box instead of showing a broken icon.
  4. Errors are the API's words. A 429 shows 429 ER0203: … in the status line, which tells you it is the rate limit and not your code.

On the free plan the description is a 200-character preview, per_page maxes out at 10, and articles arrive with a 12-hour delay. The "4 min ago" in the screenshot comes from a paid key; a free key shows "12 h ago" on everything. Say so in your UI or people will think the clock is broken.

Step 4: deploy to GitHub Pages

  1. Create a repository, add index.html, push.
  2. Settings → Pages → Source: "Deploy from a branch", branch main, folder / (root). Save.
  3. Wait about a minute. The site is at https://YOUR-USER.github.io/REPO/.
  4. If you used a live key, add that origin to the key's referrer rule now.

This is where most vanilla JS news app tutorials quietly die. They are written against an API that only allows browser calls from localhost, the page works during the tutorial, and the first deploy returns a CORS error. Unlike NewsAPI.org's Developer plan, which enables CORS for localhost only, APITube's free plan answers every origin with Access-Control-Allow-Origin: *, which means the same file keeps working after you deploy it. Check the free tier before you pick an API:

Provider, free tier Browser calls from a deployed site Delay Requests/day
NewsAPI.org Developer No: "CORS enabled for localhost" only 24 h 100
APITube Free Yes: Access-Control-Allow-Origin: * on every GET (measured) 12 h 100

A second thing to know about GitHub Pages: it serves files as-is. There is no environment variable, no server-side include, nowhere to hide a string. That is why Step 1 exists.

Step 5: when the key must stay secret, a 35-line Worker

The free plan allows 100 requests a day. In direct mode every visitor's first load is one request, so 100 visitors empty the quota and visitor 101 sees 429 ER0203. The way out is a proxy that holds the key and caches the answer. Cloudflare Workers is the usual choice because the free tier covers it and the Cache API needs no configuration.

// worker.js — holds the real key; the page calls this URL instead of the API.
const ALLOWED_ORIGINS = ["https://YOUR-USER.github.io", "http://localhost:8000"];
const ALLOWED_PARAMS = new Set(["title", "language.code", "is_duplicate", "per_page", "page", "sort.by", "sort.order", "fl"]);
const CACHE_TTL = 900; // seconds. 86400 / 900 = 96 upstream calls a day, whatever the traffic.

export default {
  async fetch(request, env, ctx) {
    const origin = request.headers.get("Origin") || "";
    if (!ALLOWED_ORIGINS.includes(origin)) return new Response("forbidden", { status: 403 });
    const cors = { "Access-Control-Allow-Origin": origin, "Vary": "Origin" };
    if (request.method === "OPTIONS") return new Response(null, { status: 204, headers: cors });
    if (request.method !== "GET") return new Response("method not allowed", { status: 405, headers: cors });

    const upstream = new URL("https://api.apitube.io/v1/news/everything");
    for (const [k, v] of new URL(request.url).searchParams) {
      if (ALLOWED_PARAMS.has(k)) upstream.searchParams.set(k, v);
    }
    upstream.searchParams.sort();

    const cacheKey = new Request(upstream.toString());
    let res = await caches.default.match(cacheKey);
    let hit = "HIT";
    if (!res) {
      hit = "MISS";
      const up = await fetch(upstream, { headers: { "X-API-Key": env.APITUBE_API_KEY } });
      res = new Response(up.body, { status: up.status, headers: { "Content-Type": "application/json" } });
      res.headers.set("Cache-Control", `public, max-age=${CACHE_TTL}`);
      if (up.ok) ctx.waitUntil(caches.default.put(cacheKey, res.clone()));
    }
    res = new Response(res.body, res);
    for (const [k, v] of Object.entries(cors)) res.headers.set(k, v);
    res.headers.set("X-Proxy-Cache", hit);
    return res;
  },
};
Enter fullscreen mode Exit fullscreen mode

Put the key in a secret (wrangler secret put APITUBE_API_KEY, or .dev.vars locally), set PROXY_URL in index.html to the Worker URL and API_KEY to an empty string. The Worker rebuilds the upstream URL from an allowlist of parameters, so a visitor cannot smuggle api_key= or an unrelated endpoint through it. The Cache-Control override matters: the API answers with no-store, and the Cache API refuses to store that.

We ran it with wrangler dev on a laptop rather than deploying to a Cloudflare account, so the timings are local:

Request Result Median time
Allowed origin, first call 200, X-Proxy-Cache: MISS 656 ms (12 different searches)
Allowed origin, repeat 200, X-Proxy-Cache: HIT 3 ms (12 runs)
Unknown origin, or no Origin header 403
POST 405
?api_key=stolen appended 200, parameter dropped

In headless Chrome the page showed its first headline 45 ms after load through the cached proxy, against 858 ms straight to the API.

The budget works out like this. With a 900-second TTL the default feed costs at most 96 upstream requests a day. Every distinct search term is its own cache entry with its own 96, so a search box on a free plan still needs a ceiling: cache longer, or only allow searches from a fixed list. Below 100 visitors a day, direct mode with a referrer-restricted key is fine and you skip the Worker entirely.

That is the whole decision. One HTML file is enough for the app; the number of requests a day you can afford decides whether the key sits in that file, in a referrer rule, or behind 35 lines of Worker. Copy index.html, pick the row of the table in Step 1 that matches your traffic, and push.

FAQ

How do you hide an API key in JavaScript on a static site?
An API key in client-side JavaScript cannot be hidden, because anything the browser sends the visitor can read in the network tab. The two working options are a key that is safe to expose (a test key, or a live key restricted to your domain and one endpoint) and a proxy such as a Cloudflare Worker that holds the real key and answers the page instead of the API.

Can a plain HTML file call a news API with fetch?
A plain HTML file can call a news API with fetch when the API sends Access-Control-Allow-Origin for your origin and the request stays simple: GET, no custom headers, the key in the query string. A custom auth header triggers a preflight, and for this API the preflight reply is currently malformed, so the header route fails in Chrome.

Which news API works from the browser on a free plan?
The news API that works from a deployed browser page on a free plan is one that sends Access-Control-Allow-Origin: * on every GET; APITube's free plan does, at 100 requests a day, 10 per minute, with a 12-hour delay on articles. NewsAPI.org's Developer plan restricts CORS to localhost, so it fails once deployed.

APITube is the API we used here; there is a free tier at apitube.io.

Resources

Top comments (0)