Intermediate

Live Lighthouse Scores

Real Lighthouse scores rendered as static badges, refreshed weekly by a scheduled GitHub Action, no client-side API calls.

2 min read

Live Implementation

These badges are real, committed data, not a live API call from your browser. Last refreshed August 9, 2026.

  • Performance: Performance 100
  • Accessibility: Accessibility 96
  • Best Practices: Best Practices 100
  • SEO: SEO 100
  • Performance: 100 out of 100 100 Performance
  • Accessibility: 96 out of 100 96 Accessibility
  • Best Practices: 100 out of 100 100 Best Practices
  • SEO: 100 out of 100 100 SEO

How It Works

There’s a tempting shortcut here: call the PageSpeed Insights API from the browser and render the scores live. Don’t. That means exposing an API key client-side, and a Lighthouse run takes several seconds, so every visitor would sit there waiting on a score nobody asked them to wait for.

Instead, this runs on a schedule, not a page load. A GitHub Action fires weekly, hits Google’s PageSpeed Insights API server-side (where the key can stay a secret), and writes the result straight into the repo as a committed JSON file:

- name: Fetch PageSpeed scores
  env:
      PAGESPEED_API_KEY: $
  run: |
      RESPONSE=$(curl -sf -G "https://www.googleapis.com/pagespeedonline/v5/runPagespeed" \
        --data-urlencode "url=https://11ty-demos.netlify.app/" \
        --data-urlencode "key=${PAGESPEED_API_KEY}" \
        --data-urlencode "strategy=mobile")
      # ...parses the response, writes src/_data/lighthouseScores.json
- name: Commit updated scores
  run: |
      git add src/_data/lighthouseScores.json
      git commit -m "chore: update lighthouse scores"
      git push

From there it’s just an Eleventy global data file. src/_data/lighthouse.js reads the committed JSON, and every page that wants the badges just asks for lighthouse.categories. No fetch, no loading state, no exposed key — by the time anyone sees the page, the data’s already been sitting in the repo for up to a week.

Folder Structure

.github/workflows/update-lighthouse-scores.yml ← the weekly cron job
src/_data/lighthouseScores.json                 ← committed by that job
src/_data/lighthouse.js                          ← thin data wrapper
src/_includes/components/lighthouse-scores.njk   ← the badge macro

Important Files

src/_data/lighthouse.js

import { createRequire } from "module";

const require = createRequire(import.meta.url);

export default async function () {
    return require("./lighthouseScores.json");
}

src/_includes/components/lighthouse-scores.njk

{% macro lighthouseScores(categories, isFallback) %}
<ul class="lighthouse-scores" aria-label="Lighthouse scores">
  {% for key, result in categories %}
  <li
    class="lighthouse-pill lighthouse-pill--{{ result.grade }}"
    title="{{ result.title }}{% if isFallback %} (data unavailable){% endif %}"
  >
    <span class="lighthouse-pill__dot" aria-hidden="true"></span>
    <span class="visually-hidden">{{ result.title }}:</span>
    {{ result.title }} <b>{{ result.score }}</b>
  </li>
  {% endfor %}
</ul>
{% endmacro %}

{% macro lighthouseGauges(categories, isFallback) %}
<ul class="lighthouse-gauges" aria-label="Lighthouse scores">
  {% for key, result in categories %}
  <li class="lighthouse-gauge lighthouse-gauge--{{ result.grade }}">
    <svg class="lighthouse-gauge__ring" viewBox="0 0 36 36" role="img" aria-labelledby="lighthouse-gauge-{{ key }}">
      <title id="lighthouse-gauge-{{ key }}">{{ result.title }}: {{ result.score }} out of 100{% if isFallback %} (data unavailable){% endif %}</title>
      <path
        class="lighthouse-gauge__track"
        d="M18 2.0845 a 15.9155 15.9155 0 0 1 0 31.831 a 15.9155 15.9155 0 0 1 0 -31.831"
      />
      <path
        class="lighthouse-gauge__value"
        stroke-dasharray="{{ result.score }}, 100"
        d="M18 2.0845 a 15.9155 15.9155 0 0 1 0 31.831 a 15.9155 15.9155 0 0 1 0 -31.831"
      />
      <text x="18" y="20.5" class="lighthouse-gauge__number">{{ result.score }}</text>
    </svg>
    <span class="lighthouse-gauge__label">{{ result.title }}</span>
  </li>
  {% endfor %}
</ul>
{% endmacro %}

Notes

  • The scores above are genuinely real — captured from a full local Lighthouse audit of this site during development, not placeholder numbers. Once this repo is deployed and the weekly Action has run at least once against the live URL, they’ll refresh automatically.
  • This needs a PAGESPEED_API_KEY repository secret to actually run. Without it, the Action fails loudly (exit 1) rather than silently leaving stale data.
  • This runs on its own weekly schedule, completely independent of when the site itself gets rebuilt or deployed. A deploy doesn’t refresh the scores, and a scheduled score refresh doesn’t trigger a deploy on its own (it does trigger a rebuild, since Netlify watches the repo and this workflow pushes a commit).