---
title: "React News Dashboard Tutorial 2026: SSE + TypeScript"
description: "Build a real-time React news dashboard with TypeScript and Server-Sent Events. Full code, 429-safe fetch hook, sentiment filters, Vercel deploy."
source: https://apitube.io/blog/post/react-news-dashboard-tutorial
---

# Build a Real-Time News Dashboard with React and APITube

Real-time news dashboards look simple until you actually ship one. You start with a 30-second polling loop, hit a 429 on your first Friday morning, add a retry, double the interval, and now the dashboard is neither real-time nor reliable.

This tutorial walks through a **React news dashboard** that does the three things most tutorials skip: chooses the right real-time transport for the actual workload, handles rate limits without silently breaking, and exposes the `sentiment` and `category` filters that make a news feed useful instead of noisy.

You'll use TypeScript, React 18 hooks, and the [APITube News API](https://docs.apitube.io) (disclosure: I work on APITube). End state is a dashboard with a live-updating feed, filters, and a working deploy to Vercel. Full source on GitHub — link at the end.

This article is for React developers who know hooks and TypeScript basics and want a production-shaped template, not a toy demo.


## Key Takeaways

- Use **Server-Sent Events** for news feeds that update under 10 times per second — WebSocket is overkill and needs backend infrastructure you don't need.
- Treat `429 Too Many Requests` as a UX state: show a "reconnecting in Xs" banner with exponential backoff (2 → 4 → 8 → 16 → 32 → 60 seconds, the same curve `EventSource` uses).
- Keep an in-memory buffer cap (100 articles) to avoid OOM over long sessions.
- APITube's free tier (30 requests / 30 minutes + SSE access) is enough for production experimentation.
- Full stack: React 18, TypeScript, Vite, Vercel — no Node server, no pub/sub broker, no paid SaaS.


## 1. What you'll build

**A real-time news dashboard is a single-page web app that streams articles from a news API as they are published, filters them client-side, and updates without a page refresh.** This tutorial builds one with three panels: a live feed of the latest articles, a category filter sidebar (technology, business, sports…), and a sentiment chip on each card (green / neutral / red). Articles stream in using Server-Sent Events — no page refresh, no polling loop, no backend broker.

The architecture is deliberately minimal: **no Node server, no WebSocket broker, no Pusher account**. The React app talks directly to APITube using the REST endpoint for the first render and the Server-Sent Events endpoint for live updates.

**Stack:**
- React 18 + TypeScript (Vite)
- Native `fetch` and `EventSource` — no client library
- Two custom hooks: `useRateLimitedFetch`, `useNewsStream`
- Vercel for deploy


## 2. Prerequisites

You need Node 20+, a working knowledge of React hooks (`useState`, `useEffect`, cleanup functions), and a free [APITube API key](https://apitube.io). Sign-up is email-only, no card. The free tier gives you 30 requests per 30 minutes plus SSE access — enough for this build and for production experimentation.

Basic TypeScript is assumed (interfaces, generics, union types). No backend work required.


## 3. Polling vs SSE vs WebSocket: choosing real-time transport

Before any code, pick the right mechanism. Most React dashboards default to WebSocket because that's what the "real-time" articles rank for on Google, but for news data WebSocket is almost always overkill.

**The decision framework:**

| Scenario | Update frequency | Recommended transport | Why |
|---|---|---|---|
| Polling | <1 event / 30 s | `setInterval` + fetch | Cheap, trivial, works through any proxy |
| Server-Sent Events | 1-10 events / sec, server→client only | `EventSource` | Auto-reconnect, HTTP/2 friendly, no broker |
| WebSocket | >10 events / sec or bidirectional | `ws` + server | Full-duplex but needs infra |

**News events arrive at most a few per second per filter.** That puts them squarely in SSE territory. WebSocket is overkill — events arrive well under 10 per second, so SSE wins on complexity without sacrificing latency. Use it.

You'll still implement a polling fallback for the first render (so the feed isn't empty for 0.5 s while SSE connects) and for browsers that block `EventSource` behind aggressive proxies.

### What's the best way to build a real-time feed in React?

**The best way to build a real-time feed in React is Server-Sent Events for feeds that update under ten times per second, combined with an initial REST fetch for the first render.** SSE reconnects automatically, works through proxies, and needs no server infrastructure when your data source already provides an SSE endpoint.


## 4. Project setup

Scaffold a Vite + React + TypeScript app:

```bash
npm create vite@latest news-dashboard -- --template react-ts
cd news-dashboard
npm install
npm install --save-dev @types/node
```

Create a `.env.local` in the project root:

```
VITE_APITUBE_API_KEY=your_key_here
```

Project structure (`src/` directory):

```
src/
  hooks/
    useRateLimitedFetch.ts
    useNewsStream.ts
  components/
    NewsCard.tsx
    CategoryFilter.tsx
    NewsDashboard.tsx
  types/
    news.ts
  lib/
    apitube.ts
  App.tsx
  main.tsx
```

### How do I structure a React dashboard project?

**Structure a React dashboard project around data concerns, not page layout.** Put fetch logic in `hooks/`, types in `types/`, API clients in `lib/`, and dumb presentational components in `components/`. Every component that renders a request result should get its data from a single typed hook — never call `fetch` inline. This keeps caching, retries, and rate-limit logic in one place.


## 5. Fetching news with the APITube API

Start with the REST endpoint. Define the response type once and reuse it everywhere.

**`src/types/news.ts`:**

```typescript
export interface NewsArticle {
  id: string;
  title: string;
  description: string;
  href: string;
  published_at: string;
  language: { code: string };
  author?: { name: string };
  categories?: { id: string; name: string }[];
  sentiment?: {
    overall: { score: number; polarity: 'positive' | 'neutral' | 'negative' };
    title: { score: number };
    body: { score: number };
  };
  source?: { domain: string; country?: string };
}

export interface NewsResponse {
  status: string;
  results: NewsArticle[];
  meta: { page: number; per_page: number; total: number };
}
```

**`src/lib/apitube.ts`:**

```typescript
const BASE_URL = 'https://api.apitube.io';
const API_KEY = import.meta.env.VITE_APITUBE_API_KEY;

export async function fetchNews(params: URLSearchParams): Promise<NewsResponse> {
  const res = await fetch(`${BASE_URL}/v1/news/everything?${params}`, {
    headers: { 'X-API-Key': API_KEY },
  });
  if (!res.ok) throw { status: res.status, message: res.statusText };
  return res.json();
}
```

APITube authenticates with the `X-API-Key` header (not a bearer token — a common footgun when migrating from other news APIs). The response shape is stable across `/v1/news/everything` and the SSE stream, so the same `NewsArticle` type covers both.

### How do I fetch news data in a React app?

**Fetch news data in a React app with a typed wrapper around the news API plus a custom hook that owns loading and error state.** Always render from a single hook per data source — never call `fetch` directly in a component — so rate-limit handling, caching, and auth live in one place. Pass `URLSearchParams` into the wrapper to keep query construction type-safe.


## 6. Going real-time with Server-Sent Events

APITube exposes an SSE endpoint at `/v1/news/integrations/sse-stream` that pushes articles as they're ingested, filtered by the same query parameters as the REST endpoint. The browser's native `EventSource` handles the connection, reconnection, and backoff — no library needed.

**`src/hooks/useNewsStream.ts`:**

```typescript
import { useEffect, useState } from 'react';
import type { NewsArticle } from '../types/news';

interface StreamOptions {
  category?: string;
  language?: string;
  enabled?: boolean;
}

export function useNewsStream(opts: StreamOptions) {
  const [articles, setArticles] = useState<NewsArticle[]>([]);
  const [status, setStatus] = useState<'connecting' | 'open' | 'closed'>('closed');

  useEffect(() => {
    if (!opts.enabled) return;

    const params = new URLSearchParams({
      'api_key': import.meta.env.VITE_APITUBE_API_KEY,
      'language.code': opts.language ?? 'en',
    });
    if (opts.category) params.set('category.id', opts.category);

    const url = `https://api.apitube.io/v1/news/integrations/sse-stream?${params}`;
    const source = new EventSource(url);
    setStatus('connecting');

    source.onopen = () => setStatus('open');
    source.onmessage = (event) => {
      const article = JSON.parse(event.data) as NewsArticle;
      setArticles((prev) => [article, ...prev].slice(0, 100));
    };
    source.onerror = () => setStatus('closed');

    return () => {
      source.close();
      setStatus('closed');
    };
  }, [opts.category, opts.language, opts.enabled]);

  return { articles, status };
}
```

Three details that matter in production:

1. **The API key rides in the query string** for SSE. Browsers can't set custom headers on `EventSource` requests. APITube accepts both `X-API-Key` header (REST) and `api_key` query param (SSE).
2. **Cap the in-memory buffer** at 100 articles. Without this, a busy filter like `language.code=en` will OOM a tab over a long session.
3. **Return a cleanup function** that closes the `EventSource`. React StrictMode will mount-unmount-remount in dev — without cleanup you'll open two connections and burn your rate limit.

### How do I use Server-Sent Events in React?

**Use Server-Sent Events in React by creating an `EventSource` inside a `useEffect`, updating state in the `onmessage` handler, and returning a cleanup function that calls `source.close()`.** The browser handles reconnection automatically with exponential backoff. Pass query parameters to filter the stream server-side so you don't transfer articles the UI will discard.


## 7. Handling rate limits: backoff and stale cache

The free tier is 30 requests per 30 minutes. The SSE stream doesn't count against that — but the REST fallback, initial page load, and filter changes do. Hit the limit and APITube returns `429 Too Many Requests`.

> **Rate limits are a UX problem, not a backend one.** When a dashboard hits `429 Too Many Requests`, the user needs to know the feed is frozen and for how long — not stare at silently-stale data. Show a banner, count down, and serve the last-known-good cache.

Most tutorials silently retry on 429. That pattern is fine for a background job. For a live dashboard it breaks the contract: the user sees "fresh" timestamps on data that's minutes old.

**`src/hooks/useRateLimitedFetch.ts`:**

```typescript
import { useEffect, useRef, useState } from 'react';
import { fetchNews } from '../lib/apitube';
import type { NewsResponse } from '../types/news';

interface State {
  data: NewsResponse | null;
  error: Error | null;
  retryIn: number;  // seconds until next attempt, 0 if idle
  isStale: boolean; // true when showing cached data during backoff
}

export function useRateLimitedFetch(params: URLSearchParams) {
  const [state, setState] = useState<State>({
    data: null, error: null, retryIn: 0, isStale: false,
  });
  const cacheRef = useRef<Map<string, NewsResponse>>(new Map());

  useEffect(() => {
    const key = params.toString();
    let cancelled = false;
    let attempt = 0;

    async function run() {
      // Serve stale cache immediately if we have it.
      const cached = cacheRef.current.get(key);
      if (cached) setState((s) => ({ ...s, data: cached, isStale: true }));

      while (!cancelled) {
        try {
          const data = await fetchNews(params);
          cacheRef.current.set(key, data);
          setState({ data, error: null, retryIn: 0, isStale: false });
          return;
        } catch (err: any) {
          if (err.status !== 429) {
            setState((s) => ({ ...s, error: err }));
            return;
          }
          // Exponential backoff: 2, 4, 8, 16, 32, 60 (capped) seconds.
          const delay = Math.min(2 ** (attempt + 1), 60);
          attempt += 1;

          for (let i = delay; i > 0 && !cancelled; i--) {
            setState((s) => ({ ...s, retryIn: i, isStale: true }));
            await new Promise((r) => setTimeout(r, 1000));
          }
        }
      }
    }

    run();
    return () => { cancelled = true; };
  }, [params.toString()]);

  return state;
}
```

The hook does three things you won't get from a naïve `fetch`:

- **Stale-while-revalidate**: if a cached response exists for the same query, it renders immediately while a fresh fetch runs in the background.
- **Exponential backoff capped at 60 s**: 2 → 4 → 8 → 16 → 32 → 60. This is the same curve the browser's native `EventSource` uses.
- **Exposes `retryIn`** so the UI can render a "Reconnecting in 12s…" banner. The user knows what's happening. They don't close the tab.

### How do I handle API rate limits in a React app?

**Handle API rate limits in a React app with exponential backoff on 429 responses plus a stale-while-revalidate cache.** Cache the last successful response per query key, render it immediately when the hook re-runs, and expose a `retryIn` counter so the UI can show a "reconnecting in Xs" banner instead of silently retrying. Treat 429 as a user-facing state, not a backend detail.


## 8. Filtering by category, language, and sentiment

The dashboard wouldn't be a dashboard without filters. APITube returns structured `categories`, `language`, and — uniquely — a `sentiment` object with `overall`, `title`, and `body` scores on every article.

Sentiment filtering matters more than it sounds. A crypto dashboard, a brand-monitoring tool, and a finance-news app all need to surface negative signal fast. Most news APIs hand you a raw text blob and leave scoring to you.

**`src/components/NewsDashboard.tsx`:**

```typescript
import { useMemo, useState } from 'react';
import { useRateLimitedFetch } from '../hooks/useRateLimitedFetch';
import { useNewsStream } from '../hooks/useNewsStream';
import { NewsCard } from './NewsCard';
import { CategoryFilter } from './CategoryFilter';

export function NewsDashboard() {
  const [category, setCategory] = useState<string | undefined>();
  const [sentimentFilter, setSentimentFilter] =
    useState<'all' | 'positive' | 'negative'>('all');

  const params = useMemo(() => {
    const p = new URLSearchParams({ 'language.code': 'en', per_page: '20' });
    if (category) p.set('category.id', category);
    return p;
  }, [category]);

  const initial = useRateLimitedFetch(params);
  const stream = useNewsStream({ category, language: 'en', enabled: true });

  const articles = useMemo(() => {
    const combined = [...stream.articles, ...(initial.data?.results ?? [])];
    if (sentimentFilter === 'all') return combined;
    return combined.filter(
      (a) => a.sentiment?.overall.polarity === sentimentFilter
    );
  }, [stream.articles, initial.data, sentimentFilter]);

  return (
    <div className="dashboard">
      <aside>
        <CategoryFilter value={category} onChange={setCategory} />
        <select
          value={sentimentFilter}
          onChange={(e) => setSentimentFilter(e.target.value as any)}
        >
          <option value="all">All sentiment</option>
          <option value="positive">Positive</option>
          <option value="negative">Negative</option>
        </select>
      </aside>

      <main>
        {initial.retryIn > 0 && (
          <div className="banner">
            Rate-limited — reconnecting in {initial.retryIn}s
            {initial.isStale && ' (showing cached results)'}
          </div>
        )}
        <div className="status">Stream: {stream.status}</div>
        {articles.map((a) => <NewsCard key={a.id} article={a} />)}
      </main>
    </div>
  );
}
```

`NewsCard` is a thin presentational component — just the title, source, timestamp, and a sentiment chip colored green / gray / red based on `article.sentiment?.overall.polarity`. Full source in the repo.


## 9. Deploying to Vercel

The whole app is static + client-side — no server runtime, no API routes. Vercel handles it with a single command.

```bash
npm install -g vercel
vercel
```

**Environment variable setup:**

Vite prefixes client-exposed vars with `VITE_`. That means your API key ships in the bundle and runs against APITube from the user's browser. For a personal dashboard or an authenticated admin panel that's fine. For a public marketing site, proxy through a serverless function instead.

Add the key in the Vercel dashboard under Project Settings → Environment Variables:

```
VITE_APITUBE_API_KEY=<your_key>
```

**CORS:** APITube allows any origin on both the REST and SSE endpoints when the request includes a valid API key, so no CORS configuration is needed on your side. If you switch to a proxy architecture, set `Access-Control-Allow-Origin` on your Vercel function.

Deploy and you'll have a working **react news dashboard** at `<project>.vercel.app` in under a minute.


## 10. Where to go next

The build works. The obvious extensions:

1. **Swap the in-memory cache for `localStorage`** so a tab reload doesn't show an empty feed while the SSE connects.
2. **Add `useSWR`** if you want caching across components for free. The `useRateLimitedFetch` hook shown here is intentionally dependency-free so you can see the mechanism.
3. **Add `media[]` rendering** for article thumbnails — APITube returns them in the same response.
4. **Filter by `topics[]` and `entities[]`** for an NLP-powered vertical (finance, crypto, sports). APITube's entity extraction is built in.
5. **Swap `EventSource` for `@microsoft/fetch-event-source`** if you need custom headers on the stream connection.

Full source: github.com/apitube/react-news-dashboard-tutorial


## Frequently Asked Questions

### Does this work with React Server Components?

Partially. The fetch hook runs fine on the client. `EventSource` is browser-only — you can't `await` a stream in an RSC. Render the dashboard as a Client Component (`'use client'`) and you're good.

### Can I use this pattern with a different news API?

Yes. Swap `src/lib/apitube.ts` for your provider's wrapper and update `NewsArticle` to match their response shape. The hook pattern — `useRateLimitedFetch` + `useNewsStream` — is provider-agnostic. Note that most news APIs don't expose an SSE endpoint, so you'll fall back to polling every 30-60 s.

### How do I avoid blowing through the free tier in development?

Cache aggressively during dev — set `staleTime: Infinity` in your cache layer and refresh manually. Also guard the SSE connection with an `enabled` flag so it doesn't open on every hot reload.

### Does SSE work on Safari?

Yes, since Safari 5. All modern browsers support `EventSource`. The one quirk is that Safari will silently close the connection after ~60 s of idle — APITube sends a keep-alive comment every 30 s to prevent this.

