Building an API Maintainer with GitHub Actions
2026-07-20
Static sites are great until you need dynamic data. You want to show IMDb ratings, YouTube trailers, and Steam metadata on your review pages — but fetching APIs at runtime defeats the purpose of static generation.
Here's the solution: a three-tier cache layer backed by a scheduled GitHub Actions workflow that periodically refreshes API data, commits it back to the repo, and triggers a redeploy. No runtime API calls, no database, no server-side rendering.
The architecture
| Component | What it does | Refresh trigger |
|---|---|---|
Cache modules (src/lib/scores.ts, tmdb.ts, steam.ts) | Three-tier fetch: memory → JSON file → live API | Every npm run build |
Cache files (content/scores.json, tmdb-cache.json, steam-cache.json) | Git-tracked JSON files — the source of truth at runtime | Only when live API returns new data |
| GitHub Actions: refresh-omdb-cache.yml | Cron job: rebuild, check for cache changes, commit, push | 4x/week (Sun/Mon/Wed/Fri at 6 AM UTC) |
| GitHub Actions: docker-publish.yml | Build Docker image, push to GHCR | Push to main (including cache commits) |
The key insight: API data is fetched at build time and checked into git. The production server never calls an external API. It reads from cache files that are refreshed on a schedule.
The cache layer
Each API module follows the same pattern. Here's the OMDb module that fetches IMDb ratings, Rotten Tomatoes scores, Metacritic scores, and plot summaries:
import fs from "node:fs"
import path from "node:path"
const cacheFile = path.join(process.cwd(), "content/scores.json")
const memCache = new Map<string, OMDbData>()
export async function fetchOMDbData(imdbId: string): Promise<OMDbData | null> {
if (memCache.has(imdbId)) return memCache.get(imdbId)!
const fileCache = loadCache()
if (fileCache[imdbId]) {
memCache.set(imdbId, fileCache[imdbId])
return fileCache[imdbId]
}
const key = process.env.OMDB_API_KEY
if (!key) {
console.warn("OMDB_API_KEY not set — skipping fetch for", imdbId)
return null
}
const url = `https://www.omdbapi.com/?i=${imdbId}&apikey=${key}`
const res = await fetch(url)
if (!res.ok) return null
const json = await res.json()
if (json.Response === "False") return null
const data: OMDbData = {}
if (json.imdbRating && json.imdbRating !== "N/A")
data.imdbRating = parseFloat(json.imdbRating)
if (Array.isArray(json.Ratings)) {
for (const r of json.Ratings) {
if (r.Source === "Rotten Tomatoes")
data.rtScore = parseInt(r.Value)
if (r.Source === "Metacritic")
data.metacriticScore = parseInt(r.Value)
}
}
// ... extract plot, director, year, rated, runtime, writer, totalSeasons
memCache.set(imdbId, data)
saveCache({ [imdbId]: data })
return data
}The loadCache and saveCache helpers read and write a shared JSON file, merging new entries with existing ones so you never lose previously cached data:
function loadCache(): Record<string, OMDbData> {
try {
return JSON.parse(fs.readFileSync(cacheFile, "utf-8"))
} catch {
return {}
}
}
function saveCache(entries: Record<string, OMDbData>) {
const existing = JSON.parse(fs.readFileSync(cacheFile, "utf-8"))
const merged = { ...existing, ...entries }
fs.writeFileSync(cacheFile, JSON.stringify(merged, null, 2) + "\n")
}The TMDB module (for YouTube trailers) and Steam module (for game metadata) follow the exact same pattern. All three produce JSON files in content/ that get tracked in git.
Three modules, one pattern
| Module | Cache file | API | Key detail |
|---|---|---|---|
| scores.ts | content/scores.json | OMDb API (free key) | IMDb/RT/Metacritic scores, plot, director, year, MPAA rating, runtime |
| tmdb.ts | content/tmdb-cache.json | TMDB API (access token) | Caches null for titles without trailers — avoids re-fetching dead ends |
| steam.ts | content/steam-cache.json | Steam Store API (no key) | Extracts year, description, developer, publisher, Metacritic score |
The maintainer workflow
The magic happens in .github/workflows/refresh-omdb-cache.yml:
name: Refresh OMDb Cache
on:
schedule:
- cron: "0 6 * * 0,1,3,5"
workflow_dispatch:
permissions:
contents: write
packages: write
jobs:
refresh:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 22
cache: npm
- run: npm ci
- run: npm run build
env:
OMDB_API_KEY: ${{ secrets.OMDB_API_KEY }}
TMDB_ACCESS_TOKEN: ${{ secrets.TMDB_ACCESS_TOKEN }}
- name: Commit updated caches
id: commit
run: |
git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com"
changed=0
for f in content/scores.json content/tmdb-cache.json content/steam-cache.json; do
if [ -f "$f" ]; then
git add "$f"
changed=1
fi
done
if [ "$changed" = "0" ]; then
echo "No changes to caches"
echo "caches_changed=false" >> "$GITHUB_OUTPUT"
exit 0
fi
git commit -m "chore(api): refresh review data caches"
git push
echo "caches_changed=true" >> "$GITHUB_OUTPUT"
- uses: docker/login-action@v3
if: steps.commit.outputs.caches_changed == 'true'
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- uses: docker/build-push-action@v6
if: steps.commit.outputs.caches_changed == 'true'
with:
context: .
push: true
tags: ${{ steps.meta.outputs.tags }}
build-args: |
OMDB_API_KEY=${{ secrets.OMDB_API_KEY }}
TMDB_ACCESS_TOKEN=${{ secrets.TMDB_ACCESS_TOKEN }}Here's what happens step by step:
1. Checkout + install. The workflow checks out the repo with whatever cache data is currently committed and runs npm ci.
2. Build with API keys. npm run build runs with OMDB_API_KEY and TMDB_ACCESS_TOKEN from GitHub Secrets. During the build, Next.js statically generates all pages. Each review detail page calls fetchOMDbData(), fetchTMDBTrailer(), or fetchSteamData(). If the data isn't in cache yet, it fetches live and writes the result to the JSON file.
3. Check for changes. After the build, the workflow checks if any of the three cache files were modified:
- If they did change: commits with
chore(api): refresh review data caches, pushes tomain, and builds a new Docker image. - If they didn't change: exits silently. No unnecessary commits or builds.
4. Trigger deployment. The push to main triggers docker-publish.yml, which builds and pushes a fresh Docker image to GitHub Container Registry.
The deployment pipeline
The docker-publish.yml workflow fires on any push to main:
name: Docker Publish
on:
push:
branches: [main]
workflow_dispatch:
jobs:
build-and-push:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- uses: docker/metadata-action@v5
id: meta
with:
images: ghcr.io/${{ github.repository }}
tags: |
type=sha,prefix=
type=raw,value=latest,enable={{is_default_branch}}
- uses: docker/build-push-action@v6
with:
context: .
push: true
tags: ${{ steps.meta.outputs.tags }}
build-args: |
OMDB_API_KEY=${{ secrets.OMDB_API_KEY }}
TMDB_ACCESS_TOKEN=${{ secrets.TMDB_ACCESS_TOKEN }}The Dockerfile is a multi-stage build:
FROM node:22-alpine AS builder
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
COPY . .
ARG OMDB_API_KEY
ARG TMDB_ACCESS_TOKEN
ENV OMDB_API_KEY=$OMDB_API_KEY
ENV TMDB_ACCESS_TOKEN=$TMDB_ACCESS_TOKEN
RUN npm run build
FROM node:22-alpine AS runner
WORKDIR /app
COPY --from=builder /app/.next/standalone ./
COPY --from=builder /app/.next/static ./.next/static
COPY --from=builder /app/public ./public
COPY --from=builder /app/content ./content
EXPOSE 3000
ENTRYPOINT ["node", "server.js"]Key detail: the production image has no API keys baked in for the cache modules. The cache files in content/ already contain all the data needed at runtime. API keys are only needed during the build phase so new entries can be fetched.
Why this pattern works
No runtime API calls. The production server never hits OMDb, TMDB, or Steam. It reads from local JSON files. Zero latency from external services, no rate limit concerns, no API keys on the production server.
Data stays reasonably fresh. The cron schedule runs four times a week. If a new review is added between runs, its API data is fetched during the next build. The saveCache function merges new entries with existing ones, so previously cached data is never lost.
Graceful degradation. If an API key isn't set or a fetch fails, the module returns null and the page renders without that data. The cached data still works for previously-fetched entries. The site builds successfully even when APIs are down.
Automatic deployment. When the maintainer finds new data, it commits back to the repo, which triggers a Docker image build and push. Fully automated.
What the cache files look like
An entry from content/scores.json:
{
"tt0111161": {
"imdbRating": 9.3,
"rtScore": 91,
"metacriticScore": 82,
"plot": "Over the course of several years, two convicts form a friendship...",
"director": "Frank Darabont",
"year": "1994",
"rated": "R",
"runtime": "142 min"
}
}And content/tmdb-cache.json for trailers:
{
"movie_278": "https://www.youtube.com/watch?v=PLl99DlL6b4"
}The Steam cache follows the same pattern with game-specific fields.
Setting this up for your own project
Here's what you need to replicate this pattern:
-
Three-tier cache module. Start with a function that checks memory → file → API. Use
Mapfor the in-memory tier,fs.readFileSync/writeFileSyncfor the file tier. -
Git-tracked cache files. Put your cache JSONs in a directory that's part of the repo. Make sure they're not in
.gitignore. -
Scheduled workflow. Create a GitHub Actions workflow with a
scheduletrigger. Use whatever cadence makes sense for your data. -
Conditional commit. After the build step, check
git difforgit statuson your cache files. Only commit if something changed. This prevents noisy commits and unnecessary Docker builds. -
Graceful missing keys. In your cache module, check if the API key exists before fetching. If it's missing, log a warning and return
null. This lets the site build without secrets in CI and allows the production Docker image to skip API calls. -
Merge on save. Your
saveCachefunction should read the existing file, merge new entries with...existing, ...newEntries, and write back. This prevents data loss.
The same pattern works for any API — GitHub stars, weather data, cryptocurrency prices, sports scores, RSS feeds. Any data that changes slowly (hours to days) is a good candidate.
Caveats
This approach isn't right for everything.
Good fit: Data that changes hourly or daily (ratings, scores, metadata). Data you're okay being up to 2-3 days stale. APIs with rate limits you want to avoid at runtime.
Bad fit: Real-time data (stock tickers, live sports scores). User-specific data (each user needs different API responses). Data that changes faster than your build can run.
There's also the git history consideration. Each cache commit adds a commit to main. If the cron runs daily, that's ~365 commits per year just for cache refreshes. Git handles it fine, but it's worth being aware of.
The opencode AI coding assistant wrote most of this site. If you're interested in how AI agents build production systems, the companion post covers that angle in detail.