Building thesanaullah.dev: A Portfolio CMS That Ingests Store Links and Never Hits APIs at Render Time

Aug 2026 · 4 min read

portfolio

I rebuilt thesanaullah.dev as a portfolio with a self-service admin — and one rule that shaped the whole architecture:

Public pages never hit an external API at render time.

Listing apps from the App Store, Play Store, pub.dev, GitHub, and npm is easy if every page view scrapes live. It’s also slow, brittle, and a great way to get rate-limited on launch day.

So the site doesn’t do that.

The pipeline: fetch once, store, override, refresh

paste URL in admin
      ↓
resolver registry matches provider
      ↓
resolve() → normalized auto fields + rawPayload
      ↓
preview in admin → edit what you want
      ↓
save: auto + overrides + metrics + raw (JSONB)
      ↓
public pages read DB only (static / ISR)
      ↓
nightly cron → re-resolve → update AUTO only → revalidateTag()

Three ideas do most of the work:

  1. rawPayload — store the upstream blob. When you later want a field you didn’t map, it’s already there. No migration panic.
  2. Override layer — title = overrides.title ?? auto.title. Cron may refresh stars, downloads, versions, and ratings. It must not overwrite the sentence you wrote about your own app. Play Store blurbs are rarely how you’d describe your work.
  3. Last good data wins on failure — a store outage records lastError on the document. It does not blank the public site.

A resolver registry, not a pile of ifs

Each source implements the same shape: test(url) and resolve(url).

Order matters. GitHub PR claims before GitHub repo. Fallback (Open Graph / JSON-LD) is last and always matches.

Source

How

Notes

pub.dev

Official JSON API

likes, pub points, popularity

App Store

iTunes Lookup

solid, free, official

Play Store

scrape

no public API — treat as draft

GitHub PR / repo

REST (+ README mining)

stars, topics, screenshots from README

npm

registry + downloads

version, weekly downloads

Fallback

OG / JSON-LD

best-effort

Play Store and generic scrapes set needsReview on first resolve. Nightly refresh does not re-raise that flag — otherwise clearing it would be pointless.

READMEs are first-class for pub.dev and GitHub: many packages have empty short descriptions, so the opener paragraph and screenshot candidates come from the linked README (with badge hosts filtered and image URLs HEAD-checked).

Merge rules that respect the operator

On refresh, adoption is not “always overwrite.”

A field is adopted from the new resolve only if you haven’t taken ownership — meaning the current value still matches what the previous auto write put there. Empty fields adopt. Once you change a title, the cron leaves it alone.

That lives in a Payload beforeChange hook (resolveSource). Forced resolve (req.context.forceResolve) is how the nightly job re-runs without changing the URL.

Cron without pretending Vercel Hobby is infinite

Refresh is a secured /api/cron/refresh endpoint, triggered by a GitHub Actions schedule (not Vercel Cron’s Hobby limits). It force-resolves items, then revalidateTag so metrics-driven pages actually update.

Locally the same logic is pnpm payload run scripts/refresh-items.ts.

Media: source CDN by default, R2 when you care

Icons and screenshots usually stay as URLs from auto (store/CDN). Updating a listing can update the site without a re-upload. Manual icon / gallery uploads still win when set — those (and the CV PDF) live on Cloudflare R2.

Hosting is Vercel for Next.js; DNS sits on Cloudflare so an R2 custom domain stays possible. Database is Neon Postgres. CMS is Payload 3 embedded in the same app.

What this is for

The site isn’t only a résumé page. It’s how I keep apps, packages, PRs, and writing in one admin without hand-copying store metadata every week — and without making visitors depend on iTunes being up.

Live: https://thesanaullah.dev

What I’d still harden

Play Store scraping will always be the weakest link — keep needsReview honest. Resolver smoke tests against live endpoints catch upstream shape changes that mocks would hide. And commercial-use limits on Vercel Hobby are worth remembering if the portfolio ever becomes a hard sales funnel.

The bet: ingest at write time, serve from your own truth, refresh metrics without rewriting your voice.

If you’re building a portfolio of shipped products: don’t scrape on every page view. Resolve once, merge carefully, fail soft.

  • nextjs
  • payload-cms
  • portfolio
  • ingestion
  • typescript
  • full-stack
  • seo