holas.pl scores 100 in all four Lighthouse categories on mobile — Performance, Accessibility, Best Practices, and SEO. Mobile is the stricter test: slower simulated CPU, throttled network, tighter scoring thresholds. Getting all four to 100 there means desktop takes care of itself.

Lighthouse mobile: 4×100 — Performance, Accessibility, Best Practices, SEO

This post goes through what actually drives each number. Most of it isn't optimisation work — it's a side effect of how the site is built.

Performance

The main reason the performance score is 100 is that nginx serves pre-rendered HTML files with no PHP involved. There is no database query, no template rendering, no framework bootstrap on every request. A file comes off disk and goes to the client. A Raspberry Pi 5 handles this without breaking a sweat.

Everything else follows from there:

Assets are hashed and immutable. JavaScript and CSS files compiled by Symfony's AssetMapper get a content hash in the filename (app-a1b2c3d4.css). nginx serves them with Cache-Control: public, max-age=31536000, immutable — one year, no revalidation. On repeat visits, the browser serves everything from cache. On deploy, the hash changes and the new file is fetched.

JavaScript is minimal and non-blocking. The site uses native ES module imports via a browser-native importmap — no bundler, no webpack, no jQuery. There are seven small JS files: app.js, contact.js, cookie-banner.js, lightbox.js, locale-redirect.js, nav-toggle.js, tagline.js. None of them block rendering. Search is powered by Pagefind, a WASM-based static search index that loads lazily — only on the search page, only when needed.

Images are WebP. Featured images are stored as WebP at 1280×720. No large uncompressed JPEGs.

No render-blocking resources. There is no <link rel="stylesheet"> to an external font CDN, no synchronous third-party script loaded in <head>. The CSS is compiled locally and served as a hashed asset.

Accessibility

<html lang> is set per locale. Every page has the correct language attribute — lang="en" for English pages, lang="pl" for Polish. This is set in the base Twig template based on the current locale, not hardcoded.

Semantic HTML throughout. The layout uses <nav>, <main>, <article>, <aside>, <footer> — not a sequence of <div> elements. Headings follow a logical hierarchy: one <h1> per page, <h2> for top-level sections, <h3> below that.

Color contrast passes. The site uses a Monokai-derived dark palette: #F8F8F2 text on #242424 background. That's a contrast ratio of 15.5:1, well above the WCAG AA threshold of 4.5:1.

All images have alt attributes. This is enforced in the Twig templates — the <img> tag always outputs the alt text from the content item's frontmatter.

Viewport meta tag is present. Every page includes <meta name="viewport" content="width=device-width, initial-scale=1">.

Best Practices

HTTPS. The site runs behind Cloudflare, which handles TLS termination. All HTTP requests are redirected to HTTPS.

Security headers. nginx sets the full set on every response:

add_header X-Frame-Options           "SAMEORIGIN"                       always;
add_header X-Content-Type-Options    "nosniff"                          always;
add_header Referrer-Policy           "strict-origin-when-cross-origin"  always;
add_header Permissions-Policy        "camera=(), microphone=(), geolocation=()" always;
add_header Content-Security-Policy   "default-src 'self'; ..." always;

The CSP required some care — wasm-unsafe-eval for Pagefind's WASM bundle, and challenges.cloudflare.com as an allowed frame source for the Turnstile CAPTCHA on the contact form. Everything else is 'self'.

No deprecated APIs. The site doesn't use document.write, XMLHttpRequest, <table> layouts, or anything else Lighthouse flags as a deprecated practice.

No mixed content. Every external resource (Cloudflare Turnstile script) is loaded over HTTPS.

SEO

Pre-rendered HTML. Crawlers receive complete HTML — every heading, paragraph, code block, and link is in the source. There is no client-side rendering to wait for, no JavaScript required to see the content.

Sitemap with hreflang. The sitemap at /sitemap.xml lists all posts and pages for both locales. Each entry includes <xhtml:link rel="alternate" hreflang="..."> pairs pointing to the EN and PL versions. If a post exists only in one language, the alternate entry is omitted.

hreflang in <head>. Every page includes <link rel="alternate" hreflang="..."> tags for both locales. The language switcher uses the same translation map — built from co-located en.md/pl.md files in each post directory.

Canonical URLs. Each page includes <link rel="canonical" href="..."> pointing to the authoritative URL for that page.

OpenGraph meta. Every page has og:title, og:description, og:image, and og:url. These are populated from frontmatter — title, description, and image fields map directly to the OG tags in the base template.

Descriptive titles and meta descriptions. Frontmatter title and description fields are required. The site doesn't have any pages with default or missing meta descriptions.

What wasn't automatic

Most of the above follows from the architecture — static files, minimal JS, pre-rendered HTML. But a few things required deliberate work.

Accessibility attributes. The lang attribute, alt text enforcement, heading hierarchy, and semantic element choices all had to be written into the templates. They don't appear by themselves.

The CSP. Getting the Content-Security-Policy right took iteration. Pagefind uses WebAssembly, which requires wasm-unsafe-eval. Cloudflare Turnstile loads from challenges.cloudflare.com and needs a frame-src exception. Every third-party resource requires an explicit CSP exception — adding one without checking breaks the score.

hreflang. The TranslationMapBuilder service builds the {directoryKey → {locale → url}} map from co-located content files. If a post exists only in one locale, the hreflang entry for the missing locale is omitted rather than pointing to a non-existent URL. This required a deliberate fallback in the template, not just a loop over all locales.

The result

The 4×100 isn't the outcome of an optimisation sprint. It's what you get when a site serves static files, uses minimal JavaScript, has proper HTML structure, and sets the security headers that should be on every production site anyway.

The architecture is described in detail in Part 2 of this series (how Symfony generates the static HTML) and Part 4 (how nginx serves it in production).