Abdulaziz Akyol

WordPress to Astro: cutting a 3 MB page to 300 KB, Lighthouse 98

Software development
25 September 2026 · 10 min read · Abdulaziz Akyol

This is a case study of moving abdulazizakyol.com from an old WordPress install to a fully static site built with Astro 5, followed by a round of Core Web Vitals work. In mobile Lighthouse tests the home page performance score went from 62 to 98, Largest Contentful Paint (LCP) dropped from 16.8 seconds to 1.9 seconds, and page weight fell from 2,975 KiB to 309 KiB.

Below, each technique follows the same pattern: why, how, and the code. The snippets come from the site’s actual repository; I note where I shortened them.

What was the problem?

For a personal site, the real cost of WordPress is not hosting, it is upkeep: core, theme and plugin updates, a separate security exposure for every plugin, and pages that break when a plugin is removed. For a site whose content does not change often, that burden is out of proportion.

The second problem was speed. On mobile the home page downloaded close to 3 MB and the largest image appeared after 16.8 seconds. On a mobile network, that means a visitor staring at a blank or half-built page for a long time.

The decision: a static site with nothing running on the server. Astro 5 renders every page to HTML at build time and the output is uploaded to Cloudflare Pages. No PHP, no database, no admin panel; no login form to attack and no plugin to forget to update. Posts live in the repository as Markdown files.

How did we measure?

All measurements were taken on the live site with Lighthouse’s mobile profile (simulated 4G). “Before” numbers come from the old WordPress site, “after” numbers from the new one. Two caveats matter:

  1. A Lighthouse score is a range. The home page varied between 87 and 98 across runs. The Chrome team also recommends thinking of the score as a distribution rather than one number (Lighthouse performance scoring).
  2. Lighthouse is a lab test. Core Web Vitals are assessed on real-user data at the 75th percentile: LCP ≤ 2.5 s, Interaction to Next Paint (INP) ≤ 200 ms, Cumulative Layout Shift (CLS) ≤ 0.1 (web.dev: Web Vitals). Lighthouse does not measure INP during a page load; its closest lab proxy is Total Blocking Time (TBT).

Since Lighthouse 10 the performance score is weighted as follows: TBT 30%, LCP 25%, CLS 25%, First Contentful Paint and Speed Index 10% each. LCP and CLS together make up half of the score, which is why working on images paid off the most.

What we changed

1. Images: WebP variants at the end of the build

Why: Photos were most of the page weight. Sending a 1920 px JPEG to a phone means several times more data than the screen can use. Astro’s Image and Picture components only process images under src/; files in public/ are copied as-is. To keep the old WordPress URLs (/uploads/2020/07/...) working, the images stayed in public/uploads, so we wrote our own step.

How: A small Astro integration that runs when the build finishes (the astro:build:done hook) uses sharp to create WebP variants of every JPEG/PNG at 360, 540, 720, 960, 1280, 1600 and 1920 px; nothing is upscaled. Outputs are cached in node_modules/.cache: the 83 images take about 5 seconds on the first build and are copied instantly from the cache afterwards.

The rules live in one file, shared by the integration and the components:

// src/lib/responsive.mjs
// /uploads/2020/07/photo.jpeg  →  /_img/2020/07/photo-640.webp, -960.webp, ...
export const WIDTHS = [360, 540, 720, 960, 1280, 1600, 1920];
export const QUALITY = 72;

export const isOptimizable = (src) => typeof src === 'string' && /^\/uploads\/.+\.(jpe?g|png)$/i.test(src);

/** Variant widths for a given original width (never upscales). */
export function variantWidths(originalWidth) {
  const ws = WIDTHS.filter((w) => w < originalWidth);
  ws.push(Math.min(originalWidth, WIDTHS[WIDTHS.length - 1]));
  return [...new Set(ws)];
}

export const variantPath = (src, w) => src.replace(/^\/uploads\//, '/_img/').replace(/\.(jpe?g|png)$/i, `-${w}.webp`);

export const srcsetFor = (src, originalWidth) =>
  variantWidths(originalWidth).map((w) => `${encodeURI(variantPath(src, w))} ${w}w`).join(', ');

Variant generation, running six jobs in parallel (shortened):

// integrations/responsive-images.mjs — inside buildVariants()
for (const w of variantWidths(width)) {
  const rel = variantPath(src, w).slice(1);
  const cached = join(CACHE, rel), out = join(root, rel);
  if (!(await newer(cached, orig))) {            // not cached yet, or the source is newer
    await mkdir(dirname(cached), { recursive: true });
    await sharp(orig).resize({ width: w, withoutEnlargement: true })
      .webp({ quality: QUALITY, effort: 5 }).toFile(cached);
  }
  await mkdir(dirname(out), { recursive: true });
  await copyFile(cached, out);                    // copy into dist/_img/...
}

On the component side, the responsive(src, sizes) helper returns srcset, sizes, width and height. Having dimensions in the HTML reserves the image’s space before it loads, which keeps CLS at 0:

// src/lib/img.ts — usage: <img {...responsive(src, '(max-width: 560px) 100vw, 400px')} />
export function responsive(src: string, sizes: string) {
  const d = dims(src); // width and height read from the JPEG/PNG header
  if (!d || !isOptimizable(src)) return { src, ...d };
  return { src, srcset: srcsetFor(src, d.width), sizes, width: d.width, height: d.height };
}

2. Automatic srcset for images inside posts

Why: Blog posts contain Markdown and legacy HTML; editing every <img> tag by hand does not scale.

How: The same integration walks every generated HTML file and adds srcset, sizes, dimensions, decoding="async" and loading="lazy" to /uploads/ images that lack a srcset. Images that already have fetchpriority, meaning LCP candidates, never get lazy:

// integrations/responsive-images.mjs
const PROSE_SIZES = '(max-width: 820px) calc(100vw - 32px), 760px';

function rewriteImg(tag, meta) {
  const src = attr(tag, 'src');
  if (!src || attr(tag, 'srcset') !== undefined) return tag;
  const key = decodeURI(src);
  const m = meta.get(key);
  if (!m) return tag;
  const add = [];
  add.push(`srcset="${srcsetFor(key, m.width)}"`);
  if (attr(tag, 'sizes') === undefined) {
    const w = Number(attr(tag, 'width'));
    add.push(`sizes="${w && w <= 480 ? `${w}px` : PROSE_SIZES}"`);
  }
  if (attr(tag, 'width') === undefined && attr(tag, 'height') === undefined) add.push(`width="${m.width}" height="${m.height}"`);
  if (attr(tag, 'decoding') === undefined) add.push('decoding="async"');
  if (attr(tag, 'loading') === undefined && attr(tag, 'fetchpriority') === undefined) add.push('loading="lazy"');
  return tag.replace(/^<img/, `<img ${add.join(' ')}`);
}

3. The LCP image: an img and a preload instead of background-image

Why: The hero and page header images used to be CSS background-image. The browser’s preload scanner cannot see an image referenced from CSS while it reads the HTML; the request only starts after styles are computed. web.dev’s LCP guide treats this wait as a separate component, “resource load delay”, and says never to lazy-load the LCP image.

How: The image is now a real <img> with fetchpriority="high", srcset and sizes. The <head> also carries a preload with imagesrcset, so the browser decides which width to fetch right at the top of the HTML:

---
// src/layouts/Base.astro (relevant lines)
// Page header image: the LCP element on inner pages. <img> + preload for early discovery.
const bannerImg = !hero && banner ? responsive(banner, '100vw') : undefined;
---
<head>
  <link rel="preload" href="/fonts/manrope-latin.woff2" as="font" type="font/woff2" crossorigin />
  <link rel="preload" href="/fonts/big-shoulders-display-latin.woff2" as="font" type="font/woff2" crossorigin />
  {bannerImg?.srcset && <link rel="preload" as="image" imagesrcset={bannerImg.srcset} imagesizes="100vw" fetchpriority="high" />}
</head>
<body>
  <header class="page-head">
    {bannerImg && <img class="bg" {...bannerImg} alt="" fetchpriority="high" decoding="async" />}
  </header>
</body>

On the home page hero, the image fills the height rather than the width on a portrait screen, so sizes is (orientation: portrait) 75vh, 100vw. A small detail, but it is what lets a phone pick the right variant.

4. The other hero slides: only when their turn comes

Why: If every slideshow image downloads on first load, they all compete with the LCP image for bandwidth.

How: The first slide ships with the HTML; the others keep their srcset in data-srcset and get it assigned after the page has loaded, just before their turn. With Save-Data on, on a 2G connection, or when the user prefers reduced motion (prefers-reduced-motion), the slides do not rotate at all:

// src/views/Home.astro — <script>
const hero = document.querySelector<HTMLElement>('.hero');
const slides = [...document.querySelectorAll<HTMLImageElement>('.slide')];
const conn = (navigator as any).connection;
const quiet = matchMedia('(prefers-reduced-motion: reduce)').matches || conn?.saveData || /(^|-)2g$/.test(conn?.effectiveType ?? '');
if (hero && slides.length > 1 && !quiet) {
  const every = Number(hero.dataset.interval) || 5000;
  const load = (img: HTMLImageElement) => {
    if (img.dataset.srcset) { img.srcset = img.dataset.srcset; delete img.dataset.srcset; }
    return img.decode().catch(() => {});
  };
  let i = 0;
  const tick = async () => {
    if (document.hidden) return;
    const n = (i + 1) % slides.length;
    await load(slides[n]);
    slides[i].classList.remove('active'); slides[n].classList.add('active'); i = n;
    load(slides[(n + 1) % slides.length]);
  };
  addEventListener('load', () => { setTimeout(() => load(slides[1]), every - 1500); setInterval(tick, every); }, { once: true });
}

5. Fonts: self-hosted woff2 and two preloads

Why: Google Fonts means a connection to another domain and a render-blocking CSS request.

How: Only the latin and latin-ext subsets of three font families are served as woff2 from /fonts. Turkish letters such as ğ, ş and İ live in latin-ext; thanks to unicode-range, that file downloads only when a page needs it. The two files used above the fold are preloaded in the Base.astro snippet above. A font preload needs the crossorigin attribute; without it the browser fetches the file twice.

/* src/styles/fonts.css — one of six declarations */
@font-face {
  font-family: 'Manrope'; font-style: normal; font-weight: 400 700; font-display: swap;
  src: url('/fonts/manrope-latin-ext.woff2') format('woff2');
  unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF;
}

6. Inlining the CSS

Why: An external stylesheet blocks rendering; the browser paints nothing until it arrives.

How: One setting in Astro: build.inlineStylesheets: 'always'. The default, 'auto', only inlines small stylesheets. This site’s CSS is small, so inlining it in every page is cheaper than waiting for a separate request. On sites with large CSS the trade-off can flip: the same CSS is downloaded again with every page and you lose the benefit of the browser cache.

// astro.config.mjs
import { defineConfig } from 'astro/config';
import responsiveImages from './integrations/responsive-images.mjs';
export default defineConfig({
  site: 'https://www.abdulazizakyol.com',
  // Every URL ends with "/", which is also how Cloudflare Pages serves them,
  // so internal links never hit a 308 redirect and there is a single canonical.
  trailingSlash: 'always',
  // The CSS is small (~13 KB); inlining it removes render-blocking requests.
  build: { format: 'directory', inlineStylesheets: 'always' },
  integrations: [responsiveImages()],
});

7. YouTube: a preview that loads the player on click

Why: An embedded YouTube iframe downloads the player’s scripts and styles even if the visitor never presses play.

How: The page only holds a link with a thumbnail and a play icon. On click, the iframe is created with the youtube-nocookie.com domain; without JavaScript, the link simply opens the video on YouTube.

// src/views/StageItem.astro — <script>
document.querySelectorAll<HTMLAnchorElement>('a.yt').forEach((a) => a.addEventListener('click', (e) => {
  e.preventDefault();
  const f = document.createElement('iframe');
  f.src = `https://www.youtube-nocookie.com/embed/${a.dataset.id}?autoplay=1&rel=0`;
  f.title = a.dataset.title || '';
  f.allow = 'accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture';
  f.allowFullscreen = true;
  f.style.cssText = 'width:100%;height:100%;border:0';
  const box = document.createElement('div'); box.className = 'video';
  box.append(f);
  a.replaceWith(box);
}));

8. Turning off Cloudflare’s email obfuscation script

Why: Cloudflare’s Email Address Obfuscation injects a separate script (email-decode.min.js) to decode addresses on the page. On a personal site whose address is public anyway, that is an extra request, and it also hides the address from AI crawlers.

How: The <!--email_off--> and <!--/email_off--> comments documented by Cloudflare wrap the whole body in Base.astro. No dashboard setting involved; it lives in code, under version control.

9. Accessibility and an automated mobile overflow test

Why: Lighthouse’s accessibility audit flags low contrast and small, hard-to-read text. Horizontal scrolling on mobile directly hurts the experience, and there are too many page and width combinations to check by eye.

How: Low-contrast text and text below 12 px were fixed. For overflow, we wrote an automated puppeteer test: 19 pages are opened at 5 widths between 320 and 768 px, and any element sticking out of the viewport is listed. The first run found 51 issues; fixes such as letting long URLs and code lines wrap and letting headings break across lines brought that to 0. A simplified version of the test:

// overflow-test.mjs — first: npm i -D puppeteer; with the site running locally: node overflow-test.mjs
import puppeteer from 'puppeteer';

const BASE = 'http://localhost:4321';
const PAGES = ['/', '/hakkimda/', '/blog/', '/en/'];   // your own page list
const WIDTHS = [320, 360, 390, 414, 768];            // example widths

const browser = await puppeteer.launch();
const page = await browser.newPage();
let problems = 0;
for (const path of PAGES) {
  for (const width of WIDTHS) {
    await page.setViewport({ width, height: 800 });
    await page.goto(BASE + path, { waitUntil: 'load' });
    const wide = await page.evaluate(() => {
      const vw = document.documentElement.clientWidth;
      if (document.documentElement.scrollWidth <= vw) return [];   // no horizontal scroll
      return [...document.querySelectorAll('body *')]
        .filter((el) => el.getBoundingClientRect().right > vw + 1)
        .slice(0, 5)
        .map((el) => el.tagName.toLowerCase() + (typeof el.className === 'string' && el.className.trim() ? '.' + el.className.trim().split(/\s+/).join('.') : ''));
    });
    if (wide.length) { problems += wide.length; console.log(`${path} @ ${width}px →`, wide.join(', ')); }
  }
}
await browser.close();
console.log(problems ? `${problems} overflowing elements` : 'No overflow');
process.exit(problems ? 1 : 0);

10. Cache headers

Cloudflare Pages applies the rules in public/_headers as HTTP headers (Cloudflare Pages: Headers). Files whose content never changes without their name changing (fonts, WebP variants, Astro’s content-hashed files) are cached for a year as immutable, so repeat visits make no requests for them at all. The relevant part of the file:

/fonts/*
  Cache-Control: public, max-age=31536000, immutable
  Access-Control-Allow-Origin: *

/_img/*
  Cache-Control: public, max-age=31536000, immutable

/_astro/*
  Cache-Control: public, max-age=31536000, immutable

SEO and GEO: what else changed besides speed?

A fast page is useless if nobody finds it. The same migration also added:

  • Structured data: every page carries a single JSON-LD graph of Person, Organization and WebSite nodes; page-specific nodes (BlogPosting, ProfilePage and so on) link to them by @id. That makes it easier for search engines and AI assistants to match the person and the company as one entity.
  • hreflang: Turkish and English pages point to each other with tr, en and x-default.
  • llms.txt and llms-full.txt: a file formatted per the llmstxt.org proposal that lists the identity and every page. llms-full.txt carries the plain text of all posts in one file; that second one is not part of the proposal but a growing convention. Both are generated from the content at build time.
  • IndexNow: after every deploy, the URLs in the sitemap are submitted to IndexNow. Search engines such as Bing and Yandex use the protocol; Google does not, so for Google the sitemap submitted in Search Console is enough.
  • 301 redirects: old WordPress URLs (post URLs at the root, /category/, /feed/, /portfolios/) permanently redirect to the new ones via public/_redirects. A script regenerates that file before every build, so a new post is never forgotten.

llms.txt is generated by an Astro endpoint (shortened):

// src/pages/llms.txt.ts — written to /llms.txt at build time
import site from '../data/site.json';
import { allPosts } from '../lib/blog';
import { iso } from '../lib/util';

export async function GET() {
  const posts = await allPosts('tr');
  const u = (p: string) => `${site.url}${p}`;
  const lines = [
    `# ${site.name}`,
    '',
    `> ${site.description}`,
    '',
    '## Blog yazıları (Türkçe) / Blog posts (Turkish)',
    '',
    ...posts.map((p) => `- [${p.data.title}](${u(`/blog/${p.id}/`)}) – ${iso(p.data.date)}${p.data.description ? `: ${p.data.description}` : ''}`),
    '',
  ];
  return new Response(lines.join('\n'), { headers: { 'Content-Type': 'text/plain; charset=utf-8' } });
}

Results: before and after

Mobile Lighthouse, live site, simulated 4G:

PagePerformanceLCPPage weight
Home (Turkish)62 → 9816.8 s → 1.9 s2,975 KiB → 309 KiB
About (Hakkımda)67 → 939.0 s → 2.6 s1,278 KiB → 224 KiB
Blog post70 → 965.7 s → 2.4 s1,580 KiB → 298 KiB
Home (English)67 → 9313.7 s → 2.7 s—

On desktop every page scored 99–100. Accessibility, Best Practices and SEO all scored 100, and CLS was 0. The home page now weighs roughly a tenth of what it did.

An honest note: on the About page and the English home page, lab LCP is still slightly above the 2.5-second threshold, so there is room left. My takeaway: the framework change prepared the ground, but the difference came from getting the LCP image discovered early and simply not sending unnecessary bytes. I describe the same “measure first, change one thing at a time” approach on the database side in SQL Server performance tuning; it is a habit that goes back to my years in enterprise IT management.

Checklist for your own site

  1. Measure first: run Lighthouse in the mobile profile several times and note the range; if available, check the Core Web Vitals report in Search Console (real-user data).
  2. Find the LCP element. If it is an image: <img>, fetchpriority="high", no lazy loading, srcset + sizes, and a preload with imagesrcset if needed.
  3. Convert every image to WebP at several widths and set width/height.
  4. Below-the-fold images get loading="lazy" and decoding="async".
  5. Serve fonts from your own domain as woff2, pick only the subsets you need, and preload the one or two used above the fold with crossorigin.
  6. Cut render-blocking CSS; if it is small, inline it.
  7. Load third-party embeds (video, maps, chat widgets) on click.
  8. Review scripts your CDN injects (email obfuscation, analytics).
  9. Test mobile overflow automatically; fix contrast and small text.
  10. Give unchanging files a long, immutable cache header.
  11. When migrating, 301-redirect old URLs; generate structured data, hreflang, the sitemap and llms.txt automatically at build time.

Frequently asked questions

Why does moving from WordPress to Astro make a site faster?

Astro renders pages to plain HTML at build time, so no PHP or database runs on each request and a CDN can serve the whole page from cache. The bigger gains, however, come from dealing with heavy resources one by one: images, fonts and third-party scripts. Changing the framework prepares the ground; it is not enough on its own.

What is LCP and what is a good LCP value?

Largest Contentful Paint (LCP) is the moment the largest image or text block on the page is rendered. According to web.dev, 2.5 seconds or less at the 75th percentile of real page loads is considered good. Lighthouse runs a simulated lab test, so real-user data should be tracked separately.

Why does my Lighthouse score change on every run?

External factors such as network routing, server response time, the machine running the test and browser extensions all move the result. This site's home page ranged between 87 and 98 across runs. Look at the range of several runs rather than a single number.

Why doesn't Astro optimize images in the public folder?

Astro copies everything in public/ as-is; the Image and Picture components work on images under src/. To keep the old WordPress URLs we left the images in public/uploads and generated WebP variants with sharp in a small integration that runs after the build.

What is llms.txt?

llms.txt is a Markdown file at the site root that summarizes the site for AI assistants: a title, a short summary and lists of links to key pages. It is a proposal published at llmstxt.org, not a formal standard or a defined ranking factor; it simply makes it easier for assistants to understand the site correctly.

Sources

  1. Web Vitals (web.dev) web.dev
  2. Optimize Largest Contentful Paint (web.dev) web.dev
  3. Lighthouse performance scoring (Chrome for Developers) developer.chrome.com
  4. Astro Configuration Reference docs.astro.build
  5. Astro Integration API docs.astro.build
  6. Cloudflare Pages: Headers developers.cloudflare.com

AstroCore Web VitalsLCPWebPCloudflare PagesLighthouse Markdown version

Contact

Let's talk.