Live Lighthouse Scores
Real Lighthouse scores rendered as static badges, refreshed weekly by a scheduled GitHub Action, no client-side API calls.
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
- Accessibility
- Best Practices
- 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_KEYrepository 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).