A self-hosted, zero-cost movie & streaming blog. A scheduled job runs every hour, picks the highest-signal story from seven sources, researches it, writes a structured MDX post, and commits it to GitHub. The Next.js site auto-deploys.
Live: moviesrule.com — film news, reviews, and what's worth streaming.
Stack: Next.js 15 · TinaCMS · Groq (free tier) · Brave Search · Pexels · GitHub Contents API · Render.
Monthly cost at steady state: $0.
Movies Rule runs on a generic auto-blog engine. Everything that makes it a movies site lives in
src/site.config.ts— point the same engine at a different niche by editing that one file (seeCREATE-A-SITE.md).
┌─ Reddit ──┐
│ HN │
│ DEV.to │──▶ score ──▶ dedup ──▶ winner ──▶ research ──▶ LLM ──▶ MDX ──▶ git commit ──▶ deploy
│ RSS │ (pop + engagement + recency) (Brave + scrape (strict JSON
│ YouTube │ + YT transcripts) contract)
│ Brave │
└─ Trends ──┘
Each stage is its own module in src/lib/orchestrator/ and can be tested
independently. The pipeline.ts runner wires them together with per-stage
timings and graceful fallbacks — a flaky source doesn't kill the run.
The niche is set in src/site.config.ts: which subreddits, rssFeeds,
braveQueries, and trendsKeywords the pipeline pulls from. For Movies Rule
that's the movie subreddits (r/movies, r/boxoffice, r/television…), trade RSS
feeds (Variety, THR, Collider, IndieWire, /Film), and movie/streaming search
queries.
- Node 20+
- npm (this repo ships a
package-lock.json; CI usesnpm ci) - A GitHub repo to commit posts into (can be this same repo)
npm install
cp .env.example .env.local| Key | Where | Free tier |
|---|---|---|
GROQ_API_KEY |
https://console.groq.com/keys | generous free tier on openai/gpt-oss-120b |
BRAVE_API_KEY |
https://api.search.brave.com/app/keys | 2,000 queries/month on the free plan |
PEXELS_API_KEY |
https://www.pexels.com/api/new/ | Unlimited for dev use |
REDDIT_CLIENT_ID / REDDIT_CLIENT_SECRET |
reddit.com → prefs → apps (create a "script" app) | Free |
GITHUB_TOKEN |
github.com → Settings → Developer settings → Fine-grained PAT | Scope: Contents: Read/Write on the blog repo only |
CRON_SECRET |
openssl rand -hex 32 |
— |
The writer LLM defaults to Groq (openai/gpt-oss-120b, with an
automatic meta-llama/llama-4-scout-17b-16e-instruct fallback on the same
key — the primary's free tier caps at 8K tokens/minute; Scout's 30K TPM cap
gives failover real headroom). To switch to
OpenRouter, change the llm block in src/site.config.ts and set the matching
key (OPENROUTER_API_KEY). Brave, Pexels, and Reddit are optional
— any unset source is skipped (imageProvider: 'openverse' needs no image key).
Fill the keys into .env.local along with GITHUB_OWNER / GITHUB_REPO /
GITHUB_BRANCH.
# Dry run — prints the generated post, doesn't write anything
npm run generate -- --dry
# Real run — writes MDX to content/posts/ and updates content/.topic-log.json
npm run generate
# Start the dev server (Next + TinaCMS)
npm run devOpen http://localhost:3000. The seed post is visible out of the box; new posts
show up as soon as npm run generate writes them.
Every revenue surface is config/env-gated and renders nothing until you supply the corresponding id — the site works ad- and affiliate-free out of the box.
- AdSense — set the publisher id (
adsenseClientinsrc/site.config.ts, overridable viaNEXT_PUBLIC_ADSENSE_CLIENT). That loads the script, serves/ads.txt, and enables Auto Ads. For manual placements, create ad units in AdSense and set theNEXT_PUBLIC_ADSENSE_SLOT_*ids (see.env.example): two in-article units at the post's section seams, one after the body, one on the homepage listing, one in the footer. Units are lazy-initialized (they don't request an ad until scrolled near the viewport). - Affiliate — set
NEXT_PUBLIC_AMAZON_ASSOC_TAG(Amazon Associates) to enable tagged "Rent or buy on Amazon" and "Own it on Blu-ray / 4K" links in the "Where to watch" rail on film posts, and optionallyNEXT_PUBLIC_VPN_AFFILIATE_URL/_NAMEfor a region-unlock CTA. Earning links carryrel="sponsored nofollow"and a disclosure appears automatically whenever one is shown. Never commit real ids — these are env vars. - Newsletter — set
BUTTONDOWN_API_KEYto activate the subscribe endpoint; capture forms render in the footer site-wide and inline at the end of every article.
The hourly schedule lives in .github/workflows/generate.yml, which runs at
the top of every hour (cron: '0 * * * *'), executes the pipeline with
npx tsx scripts/run-local.ts, and commits any new post straight to the repo.
No serverless CPU limits, free logs, and the push triggers your host to
redeploy. This is the scheduler — your host below just serves the site.
Add the pipeline secrets (GROQ_API_KEY, BRAVE_API_KEY, PEXELS_API_KEY,
REDDIT_CLIENT_ID, REDDIT_CLIENT_SECRET) under Settings → Secrets and
variables → Actions. The workflow has contents: write and a concurrency
group so a slow run never overlaps the next tick. Use the Run workflow button
(workflow_dispatch) to trigger a one-off run.
- Push this repo to GitHub.
- Create a new Web Service on Render from this repo (Node runtime).
- Set build/start commands to
npm ci && npm run buildandnpm start. - Add every env var from
.env.localto the Render service, plusNEXT_PUBLIC_SITE_URL=https://moviesrule.com.
Render auto-deploys on every push, so each hourly commit from the Action
redeploys the site. Optionally set a RENDER_DEPLOY_HOOK_URL Action secret to
force an immediate production redeploy after each post.
npm run build && npm start and point a reverse proxy at port 3000. The GitHub
Action still drives generation; to trigger a run by hand, hit the route with
curl:
curl -H "Authorization: Bearer $CRON_SECRET" https://your-domain/api/cron/generateThe schema in tina/config.ts matches the frontmatter the pipeline emits (its
category dropdown is derived from siteConfig.categories, so the two never
drift). Start the editor with:
npm run dev # Tina runs alongside Next via the `tinacms dev` wrapperThen visit http://localhost:3000/admin/index.html to fix typos, tweak tags, or hand-write posts that follow the same structure.
Self-hosted mode (default): TinaCMS works in local filesystem mode without
any cloud credentials. scripts/build.sh skips the Tina cloud build when
credentials aren't set. For hosted editing, add NEXT_PUBLIC_TINA_CLIENT_ID +
TINA_TOKEN (free tier at tina.io).
Every generated post follows this exact shape — the system prompt in
src/lib/orchestrator/generate.ts enforces it, and the Zod schema validates the
JSON before writing:
- Lead paragraph (no heading, 3–5 sentences)
<Callout type="takeaway">— one-sentence synthesis## What happened## Why it matters<ProsCons>block with 3+ items per side## How to think about it<Callout type="warning">— optional, only if warranted## FAQwith exactly 3<Question>entries
All components are implemented in src/components/mdx/index.tsx and styled via
globals.css's .prose-editorial rules. The schema is self-healing:
over-long fields are clamped rather than rejected; only genuinely unrepairable
output (too-short body, too-few tags, malformed JSON) triggers a retry.
From src/lib/orchestrator/score.ts:
score = 0.5·popularity + 0.2·engagement + 0.3·recency
- popularity — log-scaled upvotes, normalized per-source, then weighted by
source (HN=1.0, Brave=0.9, Reddit=0.85, Google Trends=0.8, DEV=0.75, RSS=0.7,
YT=0.6). Google Trends maps each trending search's approximate traffic to the
"upvotes" axis and is filtered to the keywords in
siteConfig.sources.trendsKeywords(movie/streaming terms) so the blog stays on-niche. - engagement — comments-to-upvotes ratio (capped at 1.0)
- recency — exponential decay with a 24h half-life
Dedup uses a sorted-token fingerprint of the title, so "Dune Part Three release
date" and "Release date set for Dune Part Three" collapse to the same signature.
The topic log (content/.topic-log.json) is checked on every run and capped at
500 entries.
"no items from any source" — all seven sources failed. Usually a network
blip; check logs. Try npm run generate -- --dry after a minute.
"all top candidates already covered" — the scorer found winners, but every
one has a signature already in the topic log. Either wait for new stories or
delete recent entries from content/.topic-log.json.
"no research content scrapable" — the winner's URL and all Brave results failed to scrape (timeouts, 403s, JS-only pages). The pipeline skips gracefully; try again next tick.
Groq rate limit / 413 "request too large" — the primary model's free tier
counts input + requested output against an 8K tokens-per-minute budget at
admission, so an oversized single request is rejected outright. The pipeline
keeps requests under that budget and fails over to
meta-llama/llama-4-scout-17b-16e-instruct (30K TPM, same key) when the
primary is rate-limited or over budget; if you're iterating locally, just wait
a moment.
- Add a source: drop a new file in
src/lib/sources/, export a function returningRawItem[], and add it to thePromise.allinpipeline.ts. - Tune the niche: edit
subreddits,rssFeeds,braveQueries, andtrendsKeywordsinsrc/site.config.ts. - Tune the tone: edit
SYSTEM_PROMPTingenerate.ts. The Zod schema catches anything structurally broken. - Change the cadence: edit the
cronin.github/workflows/generate.yml(e.g.0 */2 * * *for every two hours,0 12 * * *for daily).
See CLAUDE.md for a deeper map of the codebase and conventions.
MIT — do whatever you want with it.