A news site in Flask, deployed for free
The copy-paste Flask news app calls the news API inside the request handler. On our laptop that made the business page take 10.6 seconds to load, and every visitor cost one API call: 30 page views, 30 calls. With a five-minute cache and one background thread the same 30 views cost 1 call, and the page rendered in under a millisecond. This post builds that version and deploys it on a free tier.
A Flask news app is a small Python web server that fetches headlines from a news API, keeps them in memory for a few minutes, and renders them with a Jinja template. The one below is 110 lines, has category pages, search and translated world news, and runs on a free key and a free host.
Takeaways
- Top headlines page: 310 ms cold, API median 285 ms over 30 calls.
- Category pages through
category.id: 1.8 s (health) to 10.6 s (business). Not something a visitor should wait for. - 30 views without a cache = 30 API calls; with a 300 s cache = 1 call, median <1 ms per view.
- Free plan: 10 requests/min, 10 articles/page, results 12 hours behind. The cache keeps a site inside that.
- Render free tier: 750 hours/month, sleeps after 15 min idle, wakes in about 1 minute.
This is for Python beginners who have run flask run once and want a project that survives being deployed. Everything below was measured on 16 September 2026 with a Basic-plan key, in-process with Flask's test client, on a laptop. The CSV is next to the charts.
- What we're building
- The recipe everyone copies, and where it breaks
- The code, in four parts
- What the pages cost, measured
- What the cache saves
- Deploy it for free
- Lock the key
- FAQ
What we're building
A news site with six pages and a search box:
-
/top headlines for one country -
/c/business,/c/tech,/c/sport,/c/health,/c/politicscategory pages -
/worldGerman, French and Spanish headlines shown with their English translation -
/search?q=...headline search
Files: app.py (110 lines), templates/index.html (38 lines), requirements.txt, render.yaml. No database, no JavaScript, no CSS framework.
The recipe everyone copies, and where it breaks
Search "flask news app" and the top tutorials share one shape: one route, requests.get(...) inside it, the API key pasted into the source, run it on localhost:5000, done. Three things go wrong the moment it leaves the laptop.
- The key is in the code. One of the top-ranking tutorials prints its key in the finished example. Anyone who forks the repo gets your quota.
- The API call is inside the handler. Every visitor waits for the API, and every visitor spends one request. On a free plan with 10 requests per minute, the eleventh visitor in a minute gets an error page.
- The free tier of the API those tutorials use is localhost-only. NewsAPI's developer plan returns HTTP 426 from any deployed host, so "deploy" is a step those posts skip.
We keep the key in an environment variable, move the API call behind a cache, and pick an API whose free tier works from a server. Disclosure: we work on APITube, which is the API used below. Its free tier has real limits, listed near the end, and the caching pattern works with any news API.
Step 1: the project
flask-news/
├── app.py
├── templates/index.html
├── requirements.txt
└── render.yaml
# requirements.txt
flask==3.1.*
requests==2.32.*
gunicorn==23.*
python -m venv .venv && source .venv/bin/activate
pip install -r requirements.txt
export APITUBE_API_KEY=your_key_here
Step 2: app.py, in four parts
The fetch function and the cache
import os
import threading
import time
import requests
from flask import Flask, abort, render_template, request
API = "https://api.apitube.io/v1/news"
KEY = os.environ["APITUBE_API_KEY"]
COUNTRY = os.environ.get("NEWS_COUNTRY", "us")
CACHE_TTL = int(os.environ.get("CACHE_TTL", "300")) # seconds
FIELDS = "id,title,href,description,image,published_at,language,source.domain,translations.en.title"
CATEGORIES = {
"business": "medtop:04000000",
"tech": "medtop:13000000",
"sport": "medtop:15000000",
"health": "medtop:07000000",
"politics": "medtop:11000000",
}
app = Flask(__name__)
_cache = {}
def fetch(path, _force=False, **params):
key = (path, tuple(sorted(params.items())))
hit = _cache.get(key)
if hit and not _force and time.time() - hit[0] < CACHE_TTL:
return hit[1]
r = requests.get(
f"{API}/{path}",
params={**params, "fl": FIELDS, "per_page": 20},
headers={"X-API-Key": KEY},
timeout=30,
)
r.raise_for_status()
articles = r.json()["results"]
_cache[key] = (time.time(), articles)
return articles
fetch() is the only place that talks to the API. The cache is a dict keyed by endpoint plus sorted parameters, holding a timestamp and the article list. fl= asks for nine fields instead of the full sixty-field article, which keeps the response small. The category IDs are IPTC media topics; the full list is in the category reference.
One function that says what backs each page
def page_query(slug):
"""One place that says which API call backs which page."""
if slug == "top":
return "top-headlines", {"language.code": "en", "source.country.code": COUNTRY}
if slug == "world":
return "top-headlines", {"language.code": "de,fr,es"}
# Category filters are slow on the archive: pin them to the last day and to ranked sources.
return "everything", {
"category.id": CATEGORIES[slug],
"published_at.start": "NOW-1DAY",
"source.rank.opr.min": 5,
"language.code": "en",
"source.country.code": COUNTRY,
}
Why category pages use /everything with a one-day window instead of /top-headlines is a measurement, covered below.
The routes
@app.route("/")
def home():
path, params = page_query("top")
return render_template("index.html", articles=fetch(path, **params), title="Top headlines")
@app.route("/c/<slug>")
def category(slug):
if slug not in CATEGORIES:
abort(404)
path, params = page_query(slug)
return render_template("index.html", articles=fetch(path, **params), title=slug.title())
@app.route("/world")
def world():
path, params = page_query("world")
return render_template("index.html", articles=fetch(path, **params), title="World, translated")
@app.route("/search")
def search():
q = request.args.get("q", "").strip()
articles = fetch("everything", title=q, **{"language.code": "en", "sort.by": "published_at"}) if q else []
return render_template("index.html", articles=articles, title=f"Search: {q}" if q else "Search")
The error page and the warm thread
@app.errorhandler(requests.RequestException)
def api_down(err):
status = getattr(err.response, "status_code", None)
msg = "Too many requests, try again in a minute." if status == 429 else "The news feed is unavailable right now."
return render_template("index.html", articles=[], title=msg), 503
def warm_cache():
"""Refresh every page in the background so no visitor ever waits for the API."""
while True:
for slug in ["top", "world", *CATEGORIES]:
path, params = page_query(slug)
try:
fetch(path, _force=True, **params)
except requests.RequestException:
pass # keep the stale copy, try again next round
time.sleep(2) # spread calls out under the per-minute limit
time.sleep(CACHE_TTL)
if os.environ.get("WARM_CACHE", "1") == "1":
threading.Thread(target=warm_cache, daemon=True).start()
if __name__ == "__main__":
app.run(debug=True)
The error handler catches every requests failure, including a 429 from the API, and renders the normal template with a message and a 503 status. We forced a 429 in the test run and got the page, not a stack trace. The warm thread fetches all seven pages once, sleeps CACHE_TTL, and repeats. Visitors only ever read the dict.
Step 3: the template
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{{ title }}</title>
<style>
body { font: 16px/1.5 system-ui, sans-serif; max-width: 720px; margin: 2rem auto; padding: 0 1rem; color: #111; }
nav a { margin-right: .8rem; color: #0254EC; text-decoration: none; }
form { margin: 1rem 0; }
article { display: flex; gap: 1rem; padding: 1rem 0; border-top: 1px solid #ddd; }
article img { width: 120px; height: 80px; object-fit: cover; border-radius: 4px; flex: none; }
small { color: #666; }
.orig { color: #666; font-style: italic; }
</style>
</head>
<body>
<nav>
<a href="/">Top</a>
{% for slug in ["business", "tech", "sport", "health", "politics"] %}<a href="/c/{{ slug }}">{{ slug|title }}</a>{% endfor %}
<a href="/world">World</a>
</nav>
<form action="/search"><input name="q" placeholder="Search headlines" value="{{ request.args.get('q', '') }}"> <button>Go</button></form>
<h1>{{ title }}</h1>
{% for a in articles %}
<article>
{% if a.image %}<img src="{{ a.image }}" alt="">{% endif %}
<div>
<a href="{{ a.href }}">{{ a.translations.en.title or a.title }}</a>
{% if a.translations.en.title %}<div class="orig">{{ a.title }}</div>{% endif %}
<br><small>{{ a.source.domain }} · {{ a.published_at[:16].replace("T", " ") }} · {{ a.language }}</small>
</div>
</article>
{% else %}
<p>Nothing here yet.</p>
{% endfor %}
</body>
</html>
The one line worth noticing is a.translations.en.title or a.title. Every non-English article comes with an English title and description already attached; English articles have null there, so the or falls back to the original. On the /world page all 20 of 20 articles had a translation. No translation service, no extra call.
Step 4: run it
flask run
Open http://127.0.0.1:5000. The warm thread starts with the app, so by the time you click Business the page is already in memory. Set CACHE_TTL=0 if you want to see the uncached timings yourself.
What the pages cost, measured
Two cold runs per page, cache off, one API call each. Time is the full Flask request through the test client, so it includes the API round trip and rendering.
| Page | Run 1 | Run 2 | Articles |
|---|---|---|---|
/ top headlines |
310 ms | 513 ms | 20 |
/c/business |
10,622 ms | 10,250 ms | 20 |
/c/tech |
2,909 ms | 2,506 ms | 20 |
/c/sport |
10,155 ms | 10,259 ms | 19 |
/c/health |
1,775 ms | 1,797 ms | 20 |
/c/politics |
4,643 ms | 4,734 ms | 20 |
/world |
389 ms | 575 ms | 20 |
/search?q=climate |
681 ms | 591 ms | 20 |
The plain endpoints are fast. Category filters are not: a category.id query walks a much larger slice of the index, and business and sport are the biggest categories. Our first version used /top-headlines with category.id and no date bound, and four of the five categories came back as a 502 after 30 seconds or a 500; tech alone answered, in 24.7 s. Unlike /top-headlines with a bare category.id, which failed on four of five categories, /everything with published_at.start=NOW-1DAY and source.rank.opr.min=5 answered every category under 11 s and gave the same timings on both runs, which means the date bound, not the endpoint, is what makes a category page usable. Ten seconds is still not a page load, which is the whole argument for the warm thread: the visitor reads a copy that was fetched in the background, and the 10 s happens where nobody is waiting.
What the cache saves
Thirty requests to /, twice: once with CACHE_TTL=0, once with CACHE_TTL=300.
| Setting | Page views | API calls | Median per view | Slowest view |
|---|---|---|---|---|
| Cache off | 30 | 30 | 285 ms | 461 ms |
| Cache 300 s | 30 | 1 | <1 ms | 263 ms |
The math for a real day: without a cache, calls equal page views. Unlike the copy-paste recipe, where every visitor is one API call, the cached app spends at most 12 calls an hour, 288 a day per page whatever the traffic, which means visitors no longer decide the API bill. Seven warmed pages cost 2,016 calls a day. A Free key allows 10 calls a minute; the warm loop makes 7 calls per cycle with 2 s gaps, so it fits, with room for search queries on top.
When you need which piece:
- Under 10 visitors a minute, no category pages: the plain recipe works on a Free key. Add the cache anyway; it is 8 lines.
- Any category page: you need the cache and the warm thread, because 10 s is not a page load at any traffic level.
- More than 10 uncached visitors a minute on Free, or 50 on Basic: without a cache you hit the limit and serve the 503 page.
- More than one gunicorn worker: each worker has its own dict and its own warm thread, so calls multiply by worker count. Keep
-w 1on a small instance, or move the cache to Redis.
Deploy it for free
We picked Render because its free web service runs from a render.yaml in the repo, needs no card to start, and takes environment variables from the dashboard. The numbers in this post were measured locally; the deploy steps below follow Render's documentation. What the free tier gives you, from Render's own docs: 750 instance hours a month, the service spins down after 15 minutes without traffic, and waking it takes about one minute. That minute is the price; for a personal news page it is fine, for anything with an audience it is not.
# render.yaml
services:
- type: web
name: flask-news
runtime: python
plan: free
buildCommand: pip install -r requirements.txt
startCommand: gunicorn -w 1 -b 0.0.0.0:$PORT app:app
envVars:
- key: APITUBE_API_KEY
sync: false
- key: NEWS_COUNTRY
value: us
Steps: push the four files to GitHub, in Render choose New → Blueprint, pick the repo, and when it asks for APITUBE_API_KEY paste the key (sync: false means it is never written to the file). Build takes a couple of minutes; the URL is https://flask-news.onrender.com or whatever name you chose. Every deploy restarts the process, which empties the cache, and the warm thread refills it in about 43 seconds, the sum of the seven cold fetches from run 1 (30.8 s) plus six 2 s gaps.
Two alternatives we checked. PythonAnywhere has a free tier without a card, but its free web apps only allow outbound requests to an allowlist of hosts, so check whether your API's host is on it before choosing it. Fly.io no longer has a free tier: every organization needs a card on file, and the smallest machine is $2.02 a month.
Lock the key
The key never appears in a file. Locally it lives in the shell, on Render in the dashboard. Two more things cost nothing:
- In the APITube dashboard, restrict the key to your server's outbound IP, or to your site's domain via the referrer allowlist. A blocked request returns
403 ER0601orER0602and does not count against quota. -
/v1/balanceis free to call and returns the points left on the key. A one-line cron that reads it and emails you under a threshold is the whole budget alarm.
What the Free plan gives you
The rate limits page is short and worth reading before you rely on it: 10 requests a minute, 10 articles a page (our per_page=20 is clamped to 10), 5 pages deep, and every result 12 hours behind publication. The delay applies everywhere, streams included. The Basic key we measured with has no delay, 50 requests a minute and 250 per page. So on Free the site works exactly as built, with yesterday's news and ten stories a page.
FAQ
How do you make a news app in Flask?
A Flask news app needs one route per page, one function that calls the news API, and a Jinja template that loops over the articles. The version above adds a dict cache with a time-to-live and a background thread that refreshes it, so visitors never wait on the API. The complete app is 110 lines.
How do you deploy a Flask app for free?
A Flask app deploys for free on Render with a render.yaml that sets runtime: python and startCommand: gunicorn -w 1 -b 0.0.0.0:$PORT app:app, connected as a Blueprint, with the API key set as an environment variable. Render's free tier gives 750 hours a month and sleeps after 15 idle minutes.
Which news API is free?
A free news API plan exists at most providers, with limits: NewsAPI's free plan works on localhost only, APITube's free plan works from a server but returns results 12 hours late, at 10 requests a minute and 10 articles a page. With responses cached for five minutes, those limits are workable for a small site.
How do you hide an API key in Flask?
An API key stays out of Flask code when it is read with os.environ["APITUBE_API_KEY"], exported in the shell locally, set in the host's dashboard in production, and never committed. Restricting the key by IP or domain on the API side makes a leaked key useless anywhere else.
Where this leaves you
A Flask news app that survives deployment is the recipe plus three things: the key in the environment, the API call behind a five-minute cache, and a thread that refills the cache so the 10-second category query happens off the request path. That turned 30 views into 1 API call and a 10.6 s page into a cached read, and it runs on a free key and a free host. The next step that changes the picture is a second gunicorn worker, at which point the dict becomes Redis.
APITube is one of the APIs mentioned here, and it is the one the code targets. Free tier at apitube.io; the free key is enough to run everything in this post.
Resources
- Code and data for this post:
app.py,templates/index.html,render.yaml,data.csv,plot.pyin the images folder next to this article - Flask quickstart, gunicorn docs
- Render free tier, Fly.io pricing, PythonAnywhere plans
- APITube docs: top headlines, search, rate limits and plans, categories, Flask integration
- NewsAPI pricing, for the localhost-only note


Top comments (0)