DEV Community

Henry
Henry

Posted on

How I built a multilingual news SPA in vanilla JS — architecture notes

NewsScope is a real-time news search engine: search a topic, filter by language, category and country, get live results from the NewsData.io API. No React, no bundler, no npm dependencies — just HTML, CSS and vanilla ES2020+.

This post is about a few specific decisions in the architecture that I think are worth sharing.


The module structure

The JS is 9 files, each with a single responsibility, loaded in dependency order directly in index.html:

config.js   →  i18n.js   →  data.js
     ↓              ↓           ↓
helpers.js  →  geo.js    →  ui.js
     ↓              ↓           ↓
render.js   →  api.js    →  main.js
Enter fullscreen mode Exit fullscreen mode

Every module only uses things defined in modules loaded before it. main.js registers all event listeners and calls init() — it's the only file that touches everything. config.js is the smallest file in the project, since it only defines the state object and two constants.

All app state lives in a single flat object in config.js, accessed as a global:

const S = {
  apiKey: '', query: '', activeQuery: '',
  language: 'es', category: '', country: '',
  results: [], nextPage: null,
  loading: false, hasSearched: false, error: null,
};
Enter fullscreen mode Exit fullscreen mode

No state management library. When something changes, the relevant render function gets called explicitly. Simple, and easy to trace.


Translating search intent, not just the UI

Most i18n stops at labels and button text. NewsScope has 10 predefined topic shortcuts (AI, Climate, Economy, Cybersecurity…) that trigger a search. If a user picks "Cybersecurity" while the app is set to Japanese, the keyword sent to the API should be in Japanese — not a transliteration of the English word.

The solution is a TOPIC_KEYWORDS map in data.js:

const TOPIC_KEYWORDS = {
  ai:    { es: 'inteligencia artificial', en: 'artificial intelligence', ja: '人工知能', ar: 'الذكاء الاصطناعي', /* 7 more */ },
  cyber: { es: 'ciberseguridad', en: 'cybersecurity', ja: 'サイバーセキュリティ', /* 8 more */ },
  // 8 more topics
};
Enter fullscreen mode Exit fullscreen mode

One string per language, per topic. Switching the UI language and then selecting a topic sends the right keyword for that language. The results are genuinely different.


Geo-restriction detection

Some news sources (NYT, WSJ, The Times, Le Monde…) block readers outside certain countries. Finding out after you've already clicked the link is annoying.

NewsScope checks the article URL before opening it. If the domain matches a known geo-restricted list, it shows a warning modal first with the option to continue anyway — or to disable the warning permanently (saved in localStorage, so it persists across sessions until the user clears their storage).

function isLikelyGeoRestricted(url) {
  try {
    const host = new URL(url).hostname.replace(/^www\./, '');
    return GEO_RESTRICTED.some(d => host === d || host.endsWith('.' + d));
  } catch { return false; }
}
Enter fullscreen mode Exit fullscreen mode

It's a static list, so it won't catch every case. But it handles the most common ones and the pattern is easy to extend.


Error handling that's actually useful

A search app with a generic "Something went wrong" message on every failure is frustrating to use. api.js maps every known failure to a specific, translated message with recovery actions:

function parseError(err) {
  if (err.name === 'AbortError')                 return t('errTimeout');  // 12s hard limit
  if (!navigator.onLine)                         return t('errOffline');
  if (err.type === 'auth')                       return t('errAuth');     // bad or expired key
  if (err.type === 'limit')                      return t('errLimit');    // rate limited
  if (err.type === 'server')                     return t('errServer');
  if (err.code === 'ParametersMissing' || ...)   return t('errParams', err.msg);
  return t('errGeneric', err.msg);
}
Enter fullscreen mode Exit fullscreen mode

Each error type renders with contextual action buttons ("Change key", "Try again") so the user always has a clear next step. And since the error messages go through the same t() function as everything else, they're translated too.


Tradeoffs and future improvements

ES modules — Loading 9 scripts in a fixed order works, but native type="module" would make the dependency graph explicit and statically analyzable. The reason for not using them: opening index.html directly from the filesystem (file://) blocks module imports in some browsers, and keeping zero-server-required as a constraint felt worthwhile. A future version could drop that constraint and adopt ES modules without changing any other part of the architecture.

API key explanation in the UI — The key is stored in localStorage and requests go directly from your browser to newsdata.io — no intermediary server involved. The README covers this, but surfacing that context inside the app itself would be a better experience for users who don't read documentation before using a tool.


Try it

Free API key at newsdata.io/register — takes about a minute, no credit card.

→ Live demo · GitHub

Any suggestions for improving this would be greatly appreciated. 🙌​

Top comments (0)