Nov 4, 2026
15 min read
Martin Masevski profile placeholder
Martin Masevski

How I built this site with Nuxt and Vue and kept Lighthouse at 100

An AI-generated Studio Ghibli style illustration of Martin at a café table by the Vardar river in Skopje, smiling at a laptop showing four green 100 scores. On the left, modern Skopje has the Warrior on a Horse statue and glowing Nuxt and Vue signs. On the right, past the Stone Bridge, old Skopje has the Old Bazaar, a mosque, the Kale Fortress, kilim rugs, strings of red peppers and a sleeping cat.

This is the PageSpeed report for my home page on a phone.

PageSpeed Insights mobile report for martinmasevski.dev showing 100 for Performance, Accessibility, Best Practices and SEO, and 3 of 3 for Agentic BrowsingMobile PageSpeed for my home page: four 100s on the harder of the two tests.

A perfect score is easy on a page with some text and nothing else. This one has a lot more going on: the hero is a grid you can play Game of Life on, the headline rotates, the corner shows how many people are on the site right now, and every article has a big illustrated cover. Some articles also have audio. On top of that there's a dark and a light theme, and the images switch with it: most covers and every screenshot in this post come in both versions.

This post covers what I built with Nuxt and Vue, and how all of it fits inside a 100.

Why Nuxt and Vue

I write Vue and Nuxt at my day job, and outside of it I'm a huge fanboy of both and of the community behind them. Picking them was easy. It also fits this kind of site, because Nuxt can render every page ahead of time.

It's the difference between a bakery that starts your bread after you order it and one that's been baking since dawn. At the second one, you walk in and the bread is already on the shelf. nuxt generate works like that: every page, every article and the RSS feed is baked into HTML at build time, so a visitor just picks up a finished file.

Most of the site is official Nuxt modules, configured in one nuxt.config.ts:

  • @nuxt/content turns the Markdown in content/blog/ into pages. A zod schema checks every article's frontmatter, so a typo in a tag fails the build instead of shipping.
  • @nuxt/image makes every image size and format at build time.
  • @nuxtjs/seo handles the sitemap, robots, Open Graph images and structured data.
  • nuxt-security sets the Content Security Policy and the other security headers.
  • @nuxt/a11y reports accessibility problems while I develop.
  • @nuxt/ui gives me components, and only ships the CSS for the ones I use.

The pages are served from Cloudflare Workers. The Worker is under 40 lines, and most of the time it just hands back the file.

The fun parts

A hero you can play with

Move your pointer over the hero and the squares under it light up and fade. Click, and the click seeds Conway's Game of Life, which runs for 90 generations and then fades out. The headline swaps its words every few seconds.

The Game of Life is there for a personal reason. I started programming in 2010, in my first year of high school, writing Pascal, and the Game of Life was one of the programs I wrote back then. I don't remember how I drew it anymore. It was probably characters printed in a terminal window. What I do remember is staring at it.

It's a strange kind of game, because nobody plays it. There are four simple rules about when a square lives or dies, and all you choose is where it starts. From those four rules come shapes that move across the grid, copy themselves and look... alive. Order and complexity grow out of chaos, right in front of you. Math, science and a pinch of philosophy, all in a grid of little squares.

I was mesmerized then, and I still am. Sixteen years later, putting those cells at the top of my own site felt right.

The home page hero on desktop with the rotating Testing headline, a small Game of Life pattern glowing in the grid on the right, a pause button and the live visitor badge in the cornerThe hero after one click: a Game of Life pattern on the right, the pause button under it and the live visitor badge in the corner.

The rotating headline was the first accessibility trap. A screen reader would read every word change aloud, so the animated words are hidden from it, and the heading has a fixed text for screen readers instead.

Live visitors and likes

The "2 online" badge in the corner is real. I took the idea from Daniel Roe's site. Daniel leads the Nuxt team, and his site is a good place to see what Nuxt can do.

Mine runs on one small PartyServer on a Cloudflare Durable Object, which is like one tiny always-there server per room. The site has two kinds of room: one for presence, and one per article for likes.

  • Presence uses a WebSocket. Your browser keeps a connection open to the room, and every time someone joins or leaves, the room sends the new count to everyone.
  • Likes are plain HTTP requests to the article's room. Each visitor can like an article up to 10 times, so the room stores a count per visitor and a total.

Here's the shape of it, simplified from the real server:

ts
// One class for every room. The room's name says what it's for:
// "site" for presence, "likes-<slug>" for an article's likes.
class Main extends Server {
  // Idle sockets sleep, so a quiet room costs nothing
  static options = { hibernate: true }

  // Presence: tell everyone the new count when someone joins or leaves
  onConnect() {
    this.broadcast(`connections:${countConnections()}`)
  }
  onClose() {
    this.broadcast(`connections:${countConnections()}`)
  }

  // Likes: add up to 10 per visitor, in one transaction so two clicks can't race
  async onRequest(req) {
    if (!isMyDomain(req.headers.get("origin"))) return forbidden()

    const visitor = visitorIdFrom(req)
    return storage.transaction(async (tx) => {
      const mine = (await tx.get(visitor)) ?? 0
      const total = (await tx.get("total")) ?? 0
      const added = Math.min(requestedLikes(req), MAX_LIKES - mine)
      await tx.put(visitor, mine + added)
      await tx.put("total", total + added)
      return { total: total + added, remaining: MAX_LIKES - mine - added }
    })
  }
}

The domain check stops another site from making its visitors' browsers send likes to mine. It won't stop a determined script that fakes everything, and for a like counter that's fine. The hibernation API is what keeps it cheap: when nobody is connected, the room sleeps and costs nothing while it waits.

Search that lives in the URL

The Articles page with a search box, a row of tag chips with #testing selected, the text 4 of 7 articles for #testing, and three article cards with illustrated coversThe Articles page with the #testing tag picked. The cards are filtered and the URL updates.

The search text and the selected tags are kept in the URL, so a filtered list is a link you can share. There was one catch: a prerendered page doesn't know the query string it was opened with, because it was built before anyone opened it. Nuxt's useRoute().query doesn't help at first either: the page wakes up with the route it was built for, which has no query, and only switches to the real one a moment later. So the search reads window.location once the page is in the browser.

Articles you can listen to

An article page with a Listen to this article player under the title, showing skip back, pause and skip forward buttons, a progress bar at 0:02 of 7:31 and a 1x speed menuThe audio player under the title of the timezone article. The narration was made with LocalVoice Studio.

Some articles have a narrated version, stored in a Cloudflare R2 bucket. I made the narration with LocalVoice Studio, the browser text-to-speech app I wrote about before. The player has speed controls and becomes a small floating player when you scroll away. At build time the site checks whether each article has audio, and the player only appears when it does.

Small touches

  • A light theme, with daytime covers for some articles and light versions of the screenshots in this post.
  • An optional cursor glow and a Motion toggle in the footer.
  • My time in Rome next to your time, so you know if I'm asleep.
  • An offline mode. A service worker saves the pages you read, so they open without a connection.
  • A copy button and a wrap toggle on every code block.

The bottom of the home page with the contact card, my time and your time chips, the big Martin Masevski name, footer links and the Glow On and Motion On togglesThe footer with the cursor glow on, and the Glow and Motion toggles on the right.

How the animations don't cost points

Lighthouse mostly cares about one moment: how fast the main content shows up, and how busy the browser is while it does. Animations hurt when they run during that moment.

So nothing decorative runs then. It's like a shop that restocks shelves when it's quiet, not while there's a line at the register. Everything extra waits for the browser to be idle, using requestIdleCallback:

ts
export function scheduleWhenBrowserIdle(task: () => void, timeout = 1000): () => void {
  // Safari only gained requestIdleCallback recently
  if (typeof window.requestIdleCallback === "function") {
    const idleId = window.requestIdleCallback(() => task(), { timeout })
    return () => {
      if (typeof window.cancelIdleCallback === "function") {
        window.cancelIdleCallback(idleId)
      }
    }
  }

  const timeoutId = window.setTimeout(task, timeout)
  return () => window.clearTimeout(timeoutId)
}

The live visitor badge, the like count, the audio player and the neon light-cycle effect after navigation all go through it. The article body uses Nuxt's lazy hydration too: it's in the HTML from the start, but its JavaScript only wakes up when it gets close to the screen.

Sections that slide in as you scroll share one IntersectionObserver instead of one each. A section that is already on screen when the page loads is left alone, so nothing you can see blinks. And without JavaScript the page still reads normally, because the server renders every section in place.

How the images don't cost points

The covers are the heaviest thing on the site. The trick is the same as a good waiter: bring the first dish right away, and the rest when you're ready for it.

  • The first image is the only urgent one. It gets a high fetch priority and a preload in the <head>. Every other image loads lazily, when you scroll near it.
  • @nuxt/image makes AVIF and WebP copies of each image in several widths at build time, and the browser downloads the smallest one that's still sharp on your screen.
  • Every image has a width and height, so the text doesn't jump when it loads.
  • Images and fonts are marked immutable, so a second visit doesn't download them again.

To pick a copy, the browser needs to know how wide the image will be on screen before it has laid out the page. That's what the sizes attribute tells it. I got it wrong on the article cover. I wrote it the way plain HTML does, with CSS media queries:

vue
<template>
  <!-- Before: plain HTML syntax, which Nuxt Image doesn't read -->
  <NuxtPicture
    sizes="(max-width: 640px) calc(100vw - 2rem), (max-width: 1024px) calc(100vw - 6rem), 704px"
  />

  <!-- After: Nuxt Image's own breakpoint syntax -->
  <NuxtPicture sizes="96vw lg:704px" />
</template>

Nuxt Image has its own syntax for sizes: a width, then breakpoint:width pairs. Given the plain HTML version, it kept only the last value, so it told every browser the cover was 704px wide, even on a phone.

Phone screens pack more than one pixel into each CSS pixel. The phone Lighthouse pretends to be has 1.75, so a 704px image needs 704 × 1.75 = 1,232 real pixels. The nearest copy that big is 1408px wide, so phones downloaded a 75 to 100 KB cover. The new value matches the layout: on a phone the cover is 96% of the screen, and from the lg breakpoint up it's 704px. On that same phone, 96% of 412px is about 396px, times 1.75 is about 692 pixels, so it takes the 704px copy, which is about 40 KB. That's half the bytes for the image Lighthouse times the page on.

My favorite fix is in the Worker, the small script that sees every request before Cloudflare hands back a file.

@nuxt/image saves each resized copy at an address that says how it was made. /_ipx/w_320&f_avif/cover.png means "the cover, 320 pixels wide, as AVIF". The & in it was the problem. In a web address & has a special meaning, so Cloudflare stores these files under its safe spelling, %26. When the browser asked for w_320&f_avif, Cloudflare didn't send the image. It replied "that's at w_320%26f_avif", and the browser had to ask again.

It's like arriving at a building and being sent around to the side door. You get in, but you walk twice. Every image on the page did that walk:

  • Before: the browser asks for w_320&f_avif, Cloudflare redirects it to w_320%26f_avif, the browser asks again, and only then gets the image.
  • After: the browser asks for w_320&f_avif, the Worker swaps & for %26 and fetches the image itself, and the browser gets it on the first try.

On a slow phone connection, every extra trip is a wait the visitor can feel. The whole fix is a few lines:

ts
if (url.pathname.startsWith("/_ipx/")) {
  // Static assets answer a path with "&" (as in /_ipx/w_320&f_avif/…) with a 307 to its "%26"
  // form, costing every image a round trip, so ask for the "%26" form directly.
  url.pathname = url.pathname.replaceAll("&", "%26")
  return env.ASSETS.fetch(new Request(url, request))
}

How I got the last six points

Until this week, only the home page was at 100. On a phone, the articles list scored 94 and an article scored 97.

On mobile, Lighthouse pretends to be a mid-range phone on a slow connection. The number that cost me points was Largest Contentful Paint: how long until the biggest image or block of text shows up. On a slow connection, everything that starts downloading before that moment shares the same bandwidth.

Think of the connection as a one-lane road, and the main image as an ambulance. It doesn't matter how fast the ambulance is if every car on the road is driving next to it. The fix was getting everything else to pull over.

Three things were in the way:

  • The articles list loaded too many images. Cards 2 and 3 loaded their images straight away, because on a desktop they sit in the first row. On a phone they're far down the page. The other cards were lazy, but close enough to the screen that Chrome started them anyway. About 240 KB of card images raced the first one. Now only the first card loads right away, and the rest use content-visibility: auto, so they wait until the page has painted.
  • Articles shipped code they didn't need. Every article downloaded the audio player and the mobile menu up front, even articles with no audio. Both are lazy components now. Nuxt also prefetched every lazy piece of JavaScript on first load, which I turned off with a build hook, so each piece loads when it's used.
  • Around 50 scripts asked for high priority. Nuxt preloads the app's JavaScript files, and the browser treated all of them as urgent. A small server plugin now marks them low priority, so the main image and the stylesheet go first:
ts
export function lowerScriptPriority(html: string) {
  return html.replace(
    /<(link rel="modulepreload"|script type="module")(?![^>]*fetchpriority)/g,
    '<$1 fetchpriority="low"',
  )
}

None of this is free. On a slow connection the page becomes interactive a little later, and the first click to another page can wait a moment for its JavaScript. I'm fine with that trade on a blog, where people mostly read.

PageSpeed Insights mobile report for martinmasevski.dev/blog showing 100 for Performance, Accessibility, Best Practices and SEOMobile PageSpeed for the articles list after the fix: four 100s.

PageSpeed Insights mobile report for the debugging timezone article showing 100 for Performance, Accessibility, Best Practices and SEOMobile PageSpeed for an article with an audio player: also four 100s.

How the light theme doesn't cost points

The site has a light theme now. Yesterday I wrote a whole article about it: the colors, the AAA contrast, the switch and the daytime covers. This part is only about speed.

A second theme can slow a site down in two ways: a flash of the wrong theme while the page loads, and a second copy of every picture.

The flash comes from prerendering. The HTML was built before you opened it, so it can't know which theme you picked, and it arrives in dark, the default. The color mode module puts a tiny script in the <head> that reads your saved choice and sets the theme before the browser draws anything. If you picked light, you never see a dark frame first.

The colors cost nothing extra. Every color on the site is a CSS variable, and the light theme is one block of CSS that gives those variables new values, so there's no second stylesheet and the components don't need to know a light theme exists. The circle that spreads the new theme across the screen uses the View Transitions API and only runs when you click, so it isn't part of loading at all.

The pictures took more thought. Think of a hotel room with a spare blanket in the wardrobe: it's there if you get cold, but nobody puts it on your bed unless you ask. Each article has both covers in the page, and CSS hides the one for the other theme. The daytime cover loads lazily, and a lazy image that's hidden never loads, so a reader in dark mode never downloads it. The screenshots in this post work the same way: each one has a light version, and you only download the ones for your theme.

There's one trade-off I made on purpose. The night cover is the main image Lighthouse waits for, so it's preloaded with high priority, and that preload starts before the page knows your theme. If you read in light mode, your browser downloads the night cover too, and then the daytime one. I kept it that way because dark is the default: it's what every first-time visitor sees, and it's what Lighthouse measures. That also means my 100s are dark-mode 100s. I haven't run Lighthouse in light mode, and with one extra cover to download, I'd expect it to score a little lower.

Accessibility beyond the score

Lighthouse's accessibility check catches the obvious things, like missing labels and low contrast. Most of the work it can't see.

Motion is the big one. The site follows prefers-reduced-motion, and the footer has a Motion toggle for people who never changed that setting. The toggle had a bug I like: its default value was written into every prerendered page as a cookie, so every full page load reset your choice. Now the cookie has no default, and your choice sticks.

The rest is smaller work done everywhere: visible focus outlines, and text for screen readers on the like button, the visitor badge, the audio speed and the time display. Every component has a Storybook story, and every story runs an axe check that fails the test on a violation, so a regression is caught before it reaches a page. Since the light theme arrived, every story runs twice, once in each theme.

SEO

@nuxtjs/seo does most of the work, and I mostly gave it the right data:

  • The structured data describes me once, and every article points to that same person as its author. Google can connect the site, the home page and each post.
  • Every article gets its own Open Graph image, rendered at build time from a Vue template, so nothing renders when someone shares a link.
  • There's a sitemap, an RSS feed, an llms.txt and a plain Markdown copy of every article, for readers and AI agents that don't want the HTML.

What a 100 doesn't prove

Four 100s don't mean the site is a great experience. They mean the site passed one test.

It's like a driving test. Passing proves you drove well on that route, on that day, with an examiner watching. It doesn't prove you're a good driver at night, in the rain, with a kid screaming in the back seat.

Lighthouse is the same kind of test. It loads one page, once, on a simulated phone. It doesn't see what happens when you scroll, click a filter or open a second page. Under the scores, PageSpeed has a section for what real visitors experience, and for my site it says No Data, because there aren't enough visitors yet for Google to measure.

The score can also be faked. A site can spot the Lighthouse browser and send it a stripped-down page, or skip the slow parts until someone moves the mouse. An empty page scores 100 too. The accessibility score only covers the checks a machine can run, so a page can get 100 and still be confusing with a screen reader.

So I treat 100 as a baseline: the same test every time, so I can tell when a change makes things worse. What I want is a site that feels fast and works for everyone, and the score is one way to check I haven't drifted from that.

How it stays at 100

A baseline only helps if I check it. Every pull request runs Unlighthouse on desktop and mobile in GitHub Actions and posts the scores as a comment. Unit tests sit next to almost every component and composable, Storybook stories run as browser tests, and Playwright covers the main flows end to end. When CI can't run, I run the same checks locally before I deploy.

What I took away

I kept every animation and changed when they run. The browser has one busy moment, when it first shows you the page, and anything that isn't the page waits until that moment is over: the first image loads now and the rest later, the hero renders first and the Game of Life starts after.

Nuxt made most of this a setting rather than a project. Prerendering, image sizes, lazy hydration, structured data and security headers are all modules or a few lines of config. That left me time for the fun parts, like a Game of Life nobody asked for.

The cover is Skopje. I was born in Macedonia and grew up in Italy, and Skopje is the city where old and new sit right next to each other. On one side of the river there's the old bazaar and the fortress, on the other glass buildings and neon. That's how this site is built too. The tools are new, but what makes it fast is old and simple: finished HTML files, images at the right size, and nothing running before it's needed.

///