DEV Community

Cover image for How I auto-prioritize page content on a static site (no backend)
Ramsudharsan Manoharan
Ramsudharsan Manoharan

Posted on

How I auto-prioritize page content on a static site (no backend)

I run a static site with a growing pile of small tools on it. The homepage shows a "Popular" row near the top, and for a while I just hand-picked what went in it. That worked until it didn't — the picks went stale, my guesses about what people actually used were usually wrong, and every new tool meant re-ranking a list by hand.

The catch is that a static site has no server at request time to do the deciding. So people reach for one of two bad options: hardcode the order and let it rot, or bolt on a backend and stop being static.

There's a third option that's simpler than both: decide the order at build time, from real data, and bake it into the HTML.

One ranking, computed once

You don't need per-visitor personalization to do a good job here. A single global ranking — what's popular across everyone — gets you most of the value at zero runtime cost. Compute it during the build; the CDN serves plain HTML after.

The ranking is two signals added together: an editorial seed I set by hand, plus a traffic score from analytics.

export function rankByPopularity(
  tools: readonly ToolMeta[],
  gaScores: PopularityScores,
  limit: number,
): readonly ToolMeta[] {
  return [...tools]
    .map((tool) => ({ tool, score: tool.popularity + (gaScores[tool.slug] ?? 0) }))
    .sort((a, b) => b.score - a.score || a.tool.title.localeCompare(b.tool.title))
    .slice(0, limit)
    .map((scored) => scored.tool);
}
Enter fullscreen mode Exit fullscreen mode

tool.popularity is the seed baked into each tool's metadata. gaScores[slug] is the real traffic number. New tools have no traffic yet, so ?? 0 lets the seed carry them until the data shows up — cold-start solved in one operator. The alphabetical tiebreak keeps the order stable so it never flickers between builds.

It's a pure function: no DOM, no fetch, no clock. That means I can unit-test the ranking with a fake gaScores object instead of rendering a browser to find out what came out.

Where the traffic number comes from

Once a week a small script hits the Google Analytics API, pulls 28 days of pageviews per tool page, normalizes the busiest to 1000, and writes a flat JSON file:

{ "age-calculator": 1000, "apr-calculator": 182, "base64": 91 }
Enter fullscreen mode Exit fullscreen mode

Then I commit it. The build imports it like any other module — no network call in CI, no API key in the critical path. If GA is down, the build doesn't care; it uses the last committed numbers.

This is the part I'd argue about. Don't call the API at build time — commit the JSON. You get reproducible builds, traffic shifts you can read in a git diff, and rollback like any other file. The freshness you give up is a week, which does not matter for a popularity list.

Viola! You have a automatic prioritization mechanism that works in static sites.

See it in action here - ToolsTray, a free set of browser tools — the homepage ordering there is exactly this pipeline running. Explore it yourself and leave a like if you enjoyed it.

Top comments (1)

Collapse
 
myqer_app_6825c2de8fe1f6a profile image
myqer app

Interesting