Build a Live Reads Page in Zola with Miniflux
Created:
Introduction
I wanted a reads page on this blog that reflects what I actually subscribe to instead of a hand-maintained bookmark list.
Because I already self-host Miniflux, I let the page pull feed data directly from the Miniflux API during the Zola build.
The result is a static page with live data:
- no client-side JavaScript
- no separate export step
- no manual curation every time I add or remove a feed
This post walks through how to build the same pattern in a Zola blog.
1. Create a Dedicated Page and Template
I split the feature into two files:
- A content file that defines the page metadata.
- A template that does the actual API loading and rendering.
My content file looks like this:
+++
title = "Reads"
template = "pages/read.html"
in_search_index = true
authors = ["exiguus"]
+++
This is my reading list, a collection of feeds and sites I subscribe to and that I read and found interesting. It is powered by [Miniflux](https://miniflux.app/), a minimalist and open-source feed reader that I self-host.
That keeps the page content simple while moving the API-specific logic into templates/pages/read.html.
2. Load the Miniflux API with load_data
The core of the page is Zola's load_data function.
Instead of running curl in a shell block, I load the remote JSON directly in Tera:
{% set miniflux_base_url = get_env(name="MINIFLUX_BASE_URL", default="") | trim(end_matches="/") %}
{% set miniflux_api_key = get_env(name="MINIFLUX_API_KEY", default="") %}
{% set feeds_response = load_data(
url=miniflux_base_url ~ "/v1/feeds",
format="json",
headers=["X-Auth-Token=" ~ miniflux_api_key, "Accept=application/json"],
required=false
) %}
There are two important details here:
required=falsekeeps the build from hard-failing when the API is unreachable.- The
X-Auth-Tokenheader is passed directly throughheaders=[...].
That is enough for Miniflux to return the feed list as JSON.
3. Pass Secrets Through Environment Variables
I do not hardcode credentials into the template.
Instead, the page expects:
MINIFLUX_BASE_URLMINIFLUX_API_KEY
Locally, I keep them in apps/blog/.env.
Example:
MINIFLUX_BASE_URL=https://feed.example.com/
MINIFLUX_API_KEY=replace-me
Then the blog build script sources that file if it exists.
That gives me one setup for local development and another one for CI, where the same variables come from GitHub Actions secrets.
4. Render the Feeds with Semantic HTML
I wanted the page to stay simple and readable, so each feed is rendered as an article inside a list item.
The structure looks like this:
<li>
<article>
<header>
<h1>{{ feed.title | default(value="Untitled feed") | regex_replace(pattern="<[^>]*>", rep="") }}</h1>
</header>
{% if feed.description %}
<p>{{ feed.description | default(value="") | regex_replace(pattern="<[^>]*>", rep="") }}</p>
{% endif %}
<ul>
<li>Site URL: <a href="{{ feed.site_url | default(value="#") }}">{{ feed.site_url | default(value="") }}</a></li>
<li>Feed URL: <a href="{{ feed.feed_url | default(value="#") }}">{{ feed.feed_url | default(value="") }}</a></li>
<li>Category: {{ category_title }}</li>
</ul>
</article>
</li>
Two choices matter here:
- Titles and descriptions are additionally sanitized with
regex_replace(pattern="<[^>]*>", rep=""). - Metadata lives in the
<ul>, not mixed into the main text.
That keeps the template semantic and the generated markup predictable.
5. Filter Out Broken or Low-Value Feeds
Not every feed returned by Miniflux should be shown.
I apply three filters:
- Skip feeds with
parsing_error_count >= 10 - Skip feeds titled
Videos - Skip feeds titled
Images
In Tera, the rule looks like this:
{% set feed_title_normalized = feed.title | default(value="Untitled feed") | regex_replace(pattern="<[^>]*>", rep="") | lower %}
{% if feed.parsing_error_count | default(value=0) < 10 and feed_title_normalized != "videos" and feed_title_normalized != "images" %}
This removes noisy playlist feeds, image feeds, and entries that are effectively broken in Miniflux.
6. Group by Category and Add a "Recently Added" Section
One long unsorted list is not very useful once the feed count grows.
I use two views:
Most Recently Added Feeds
This is based on the highest Miniflux id values, because the Miniflux API does not provide a timestamp and increments the ID for each new feed:
{% for feed in feeds_response | sort(attribute="id") | reverse %}
That gives me a quick "what did I add lately?" section.
Category Groups
For the main listing, I sort by category and title:
{% set feeds_sorted = feeds_response | sort(attribute="title") | sort(attribute="category.title") %}
Each category gets its own <details> block with a count in the summary, for example:
<details>
<summary>web-development (12)</summary>
...
</details>
This keeps the page usable even when the list grows large.
A full example of the template is in this Zola tera miniflux read template gist.
7. Local and CI Setup
Locally, the page reads MINIFLUX_BASE_URL and MINIFLUX_API_KEY from apps/blog/.env.
In CI, the same value is passed into the build step from GitHub Actions secrets.
That matters because Zola templates only see environment variables that already exist in the build process. Zola does not load .env files by itself.
My local setup works because the pnpm build and pnpm dev scripts source .env if the file is present.
if [ -f ./.env ]; then set -a && . ./.env && set +a; fi && zola serve --drafts
The GitHub workflows work because the build steps explicitly pass:
MINIFLUX_BASE_URLMINIFLUX_API_KEY
into the process environment:
- name: Build
run: pnpm build
env:
MINIFLUX_BASE_URL: ${{ secrets.MINIFLUX_BASE_URL }}
MINIFLUX_API_KEY: ${{ secrets.MINIFLUX_API_KEY }}Conclusion
If you already run Miniflux, building a live /reads/ page in Zola is surprisingly straightforward.
The whole pattern comes down to:
- define a dedicated page
- load the API with
load_data - pass credentials through environment variables
- filter and group the result in Tera
- render it with semantic HTML
Zola shines here because a small amount of template logic turns external data into a clean static page.
And if you want daily or weekly updates, you can just trigger a rebuild or run a scheduled job of the site and the page will always reflect the current state of your Miniflux feeds.
Resources
- Zola
load_datadocumentation: https://www.getzola.org/documentation/templates/overview/#load-data - Zola
regex_replacedocumentation: https://www.getzola.org/documentation/templates/overview/#regex-replace - Miniflux: https://miniflux.app/
- Github Schedule Action: https://docs.github.com/en/actions/using-workflows/events-that-trigger-workflows#schedule
- Template full example: https://gist.github.com/exiguus/b51fb94276ab7cecfdc036a08d062040
Feedback
Have thoughts or experiences you'd like to share? I'd love to hear from you! Whether you agree, disagree, or have a different perspective, your feedback is always welcome. Drop me an email and let's start a conversation.