Skip to content
14 min read

Auditing my Next.js portfolio: what moved and what didn't

Search Console showed my old v2 site as a referring page. That led to indexing fixes, a full accessibility pass, and a performance experiment log where most ideas failed.

  • Performance
  • SEO
  • Accessibility
  • Next.js
  • Lighthouse

Auditing my Next.js portfolio: what moved and what didn't

#TL;DR

I wanted perfect PageSpeed and Lighthouse scores and a clean Search Console. Two days later, measured on the live site:

AreaBeforeAfter
Accessibility91–96 on the pages I first measured100 on all 28 routes, light and dark
Mobile performance9797
Desktop performance100100
Best practices / SEO100100
Mobile LCP (simulated slow 4G)2.4–2.6 s2.4 s
Data transferred per page354–395 KB323–364 KB
Archived versions (v1, v2, v3)v1 and v2 indexableall three send noindex

Accessibility and indexing were real wins. Performance barely moved, and I'm glad I measured, because most of my ideas for it turned out to be wrong. This post is the log: what I found, what I fixed, what I tried and reverted.


#It started with one odd line in Search Console

URL Inspection for danielastudillo.io listed two referring pages: www.danielastudillo.io and v2.danielastudillo.io. I did not expect an old version of my site to show up there.

A referring page is just a page where Google found a link to the URL you inspected. The old v2 site links back to the apex, so of course it appeared. The line itself was harmless. What it exposed was not: I had checked each old version with curl for the first time.

HostStatusRobots signals
danielastudillo.io200indexable (intended)
www.301 to apexn/a
v1. (Gatsby)200no robots.txt, no robots meta, no canonical
v2.200index, follow, canonical pointing at the apex, robots.txt listing the apex sitemap
v3.200X-Robots-Tag: noindex, nofollow

So two old versions of the same person and brand were fully indexable, and v2 was making cross-host claims (its canonical and sitemap both named the apex) that Google treats as hints and can ignore. I kept all of them online on purpose, as a visible history of the site, so deleting them was not the answer. Making them invisible to search was.

#The fix, and the trap in it

JSON
{
  "headers": [
    {
      "source": "/(.*)",
      "headers": [{ "key": "X-Robots-Tag", "value": "noindex, nofollow" }]
    }
  ]
}

That is the whole vercel.json I added to v1 and v2. The trap is robots.txt: if you Disallow the archive, Google never fetches the pages, so it never sees the noindex, and a blocked URL can still linger in the index. I kept Allow: / on v2 so the header gets read, and dropped its sitemap, which listed apex URLs from the wrong host.

I also added rel="nofollow" to the apex's links to v1, v2 and v3, since the apex was the thing pointing Google at them. Then I checked the live headers:

Bash
for h in v1 v2 v3; do curl -sI https://$h.danielastudillo.io/ | grep -i x-robots-tag; done

All three returned noindex, nofollow once the deploys finished, and the apex returned none. Google still has to recrawl them before they actually drop out of results, which takes a while.


#Small SEO bugs the audit shook loose

None of these showed up in a Lighthouse score. All were real.

ProblemWhy it mattered
JSON-LD pointed at /images/og-image.png, which returned 404A structured-data image that does not exist
/favicon.ico returned 404, and the icon <link> was injected by JavaScript onlyCrawlers that do not run JS had no icon to find
A header rule for /manifest.json, with no such fileDead config
Every sitemap URL had lastmod set to build timeA lastmod that always says "now" teaches Google to ignore it
Sitewide FAQPage JSON-LDGoogle limits FAQ rich results to government and health sites, so it did nothing for a personal site
A google-site-verification meta tag containing the DNS TXT prefixA meta tag wants only the token, so it never verified anything. Ownership came from the DNS record

The sitemap now carries a date only where I have a real one: each post's modified date, and /blog from its newest post.


#Measuring: the part I got wrong first

I set up Lighthouse 13.5.0 from the command line against headless Chrome: mobile with the default simulated slow 4G (1.6 Mbps, 150 ms round trip, 4x CPU slowdown), plus the desktop preset. Medians of three mobile runs per page.

Three things bit me before I had a number I trusted:

  • The PageSpeed Insights API had no quota left. A keyless call returned Quota exceeded for quota metric 'Queries'. Running Lighthouse directly uses the same engine, so I did that.
  • My local build scored worse than production. Local mobile: 92, LCP 3.3 s. Live: 97, LCP 2.4–2.6 s. On localhost the observed LCP was 89 ms, and the 3.1 s figure was entirely Lighthouse's simulation. My read is that when every resource finishes before LCP is observed, the simulation counts all of them as blockers. So local runs were useful only for comparing variants against each other, never as absolute numbers.
  • My own scripts lied to me twice. A set -e in my runner aborted the whole sweep after one transient failure, silently skipping the blog post. Later, a scan of every post for contrast failures reported zero problems because my regex looked for "ratio of" when the text says "contrast of". The audit itself had listed 31 failing items on one post, and my parser had matched none of them.

I now treat a clean result from my own tooling as suspicious until I have seen it fail once.


#Performance: an experiment log

On the live site mobile LCP was 2.4–2.6 s with a score of 97. I wanted 100. The LCP element was just the hero paragraph, and on my local build the server answered in about 7 ms, so there was no slow request to blame. My first hypothesis: the page paints in a fallback font, then the web font swaps in and registers a new, later LCP candidate.

I tested one change at a time on a local production build (mobile, home page, medians of three):

ChangePerfFCPLCPTransferred
Baseline921.7 s3.3 s366 KB
Trim font preloads, including the mono font941.5 s3.0 s350 KB
↳ the same change, measured on a blog post83 (CLS 0.229)1.5 s3.0 s372 KB
Mono preload restored, rest of the trim kept921.9 s3.1 s350 KB
inlineCss: true (inline the CSS)931.1 s3.2 s401 KB
font-display: optional on the body font931.8 s3.1 s350 KB
  • Fonts. Instrument Serif was preloaded in two styles, but I only used one of them, in the footer. A Helvetica declaration was referenced by nothing at all. Removing the dead weight saved about 16 KB. Not preloading the mono font looked like a free win until the blog post's cumulative layout shift jumped from 0 to 0.229, because code blocks moved when the font arrived. Reverted, with a comment in the code explaining why.
  • Inlining the CSS cut FCP by 0.8 s and did nothing for LCP or the score, while making the page heavier. Reverted.
  • font-display: optional changed nothing, which killed my hypothesis. LCP was not waiting on the font swap.

What did explain the number was boring: in Lighthouse's simulation, LCP tracks the total bytes that load before it, not any one resource. The home page loaded about 176 KB of JavaScript (gzipped):

PieceSizeAvoidable?
React DOM and framework70 KBNo
Next app router44 KBNo
FontAwesome runtime and icons24 KBYes, for 20 icons
Everything else38 KBSmall wins only

One trap in reading chunk lists: a 38 KB chunk full of core-js is Next's noModule polyfill bundle, which modern browsers never download. It looks like waste and isn't.

My estimate, and it is only an estimate: with about 114 KB of that locked in by React and the router, a mobile 100 under this simulation is probably out of reach, and replacing FontAwesome would get me to 98 or 99. I did not do that, because 24 KB for one point is a judgment call and not an obvious win.

#Dead config I found by reading the build output

The build header says Next.js 16.4.0 (Turbopack), and the chunks had hashed names, not the vendors and react names my config asked for. That meant my entire custom webpack() hook, including a splitChunks setup I had been meaning to tune, never ran. @next/bundle-analyzer only supports webpack, so pnpm analyze was not analyzing the real bundle. framer-motion was listed as a dependency and never imported.

I deleted all of it, about 100 lines from next.config.ts and two dependencies, and used next experimental-analyze instead. A custom Cache-Control rule for /_next/static also went: Next already sends public, max-age=31536000, immutable there, which I confirmed by requesting a real chunk with and without the rule.


#Accessibility: dark mode hid half the bugs

My baseline failed four audits:

AuditWhat was wrongFix
color-contrasttext-muted/60 and /70 and /80 opacity variants on small text, as low as 2.79:129 uses became the solid muted color
label-content-name-mismatchLogo links were named "Daniel Astudillo Home" while the visible text was "DA."Name now starts with the visible label
link-in-text-blockLinks in prose were 2.98:1 against the surrounding text with no underlineUnderlined
target-sizeCard links were 19.5 px tall24 px minimum

Lighthouse reported 100 after that, in the theme my machine happened to be using: dark. I had never run light mode. When I forced it, two more problems appeared that dark mode could never have shown:

  • Buttons with dark text on the cobalt accent: 2.63:1. White text on that blue is about 6.6:1.
  • Syntax highlighting. The GitHub light theme's red, green and orange are 4.29, 4.34 and 3.27:1 on the code background. Lighthouse flagged them 200, 16 and 26 times across 18, 4 and 8 of my 21 posts. I swapped each for GitHub's newer palette value with one CSS rule apiece:
CSS
html:not(.dark) .code-block span[style*="--shiki-light:#D73A49" i] {
  color: #cf222e !important;
}

After the fix, a scan of the 21 posts that existed then, in both themes, found no failing color pair.

#A sample is not the site

I thought I was done, and I was not. My checks covered the main pages and one blog post. Then I wrote this post, ran Lighthouse on it, and accessibility came back 95. The "Reader field guide" checklists, written as - [ ], render as disabled checkboxes with no label. 19 of my 22 posts use that pattern. Most of the blog had the defect, and the one post I had been testing happened to have no checklist.

The fix lives in one place, the MDX component map: the boxes are read-only, so they are hidden from assistive technology and the item text still reads.

TSX
input: (props) =>
  props.type === "checkbox" ? (
    <input {...props} aria-hidden="true" tabIndex={-1} />
  ) : (
    <input {...props} />
  ),

Then I stopped sampling and audited every route, the 6 main pages and all 22 posts, in both themes. That found one more thing: two comparison tables whose top-left header cell was empty. Putting the word "Aspect" in it fixed both. The final run: 28 of 28 routes at 100, zero failing audits, in light and dark.

#The parts a score cannot check

Lighthouse does not test whether a keyboard user can actually get around, so I wrote a 14-check script in Chrome covering the skip link, focus indicators, reduced motion, and the mobile menu:

  • Skip link: first Tab stop, visible when focused, moves focus to <main>.
  • Reduced motion: smooth scrolling was a global default, so it is now auto under prefers-reduced-motion.
  • Mobile menu: by my reading of the code, it declared itself a modal dialog and closed on Escape, but never moved focus into it or stopped you from tabbing through the page behind it. I did not run the script against the old version. Now focus moves in, the page behind it is inert, and Escape returns focus to the toggle.

The first run scored 13 of 14. The skip link was still tabbable behind the open menu, pointing at an inert <main>. A one-selector fix, and exactly the kind of bug a score would never have caught.


#What I found along the way

The build log printed SMTP connection verified three times. My email service was created as a singleton at import time and verified its connection from the constructor, so every build worker and every cold start opened an SMTP connection, and a failure would throw inside a promise nobody awaited. It is now created lazily on first use, and a test points it at a local TCP server and asserts that constructing it opens no connection.


#What I refused to do

  • Block the archives in robots.txt. It feels like the safe move and it prevents Google from ever seeing the noindex.
  • Keep a font preload that cost a point of CLS just because it improved LCP by a fraction.
  • Chase 100 on mobile with tricks. Deferring or removing real content to nudge a lab score is not an improvement.
  • Trust local numbers as absolute. They were a comparison tool only.

#Lessons

  1. Test in both themes, and on every page. My own default theme hid half my contrast failures, and a sample of pages hid a defect that 19 of 22 posts shared.
  2. Read your build output. A Turbopack header and hashed chunk names told me my webpack config was dead before any profiler did.
  3. Change one thing at a time, and write down the ones that fail. Three of my four performance experiments did nothing or hurt.
  4. Distrust a clean result from your own scripts until you have watched it fail once.
  5. noindex and robots.txt do different jobs. If you want Google to drop something, it has to be allowed to see the instruction.
  6. A score is a proxy. Mobile performance stayed at 97, and the site is still better, because accessibility and indexing were the real defects.

#Closing thought

The headline result is boring: one number went up, one stayed put. But the useful outcome was a clearer map of the site. I know which of my pages Google can see, which 24 KB I could remove if one more point ever matters, and which of my assumptions were wrong. That is worth more than a screenshot of 100s. Lab scores also say nothing about real users, and my origin still has no field data.


#Reader field guide

Operational checklist

  • curl -I every host that serves a version of your site, and look at robots signals, canonical and sitemap on each
  • Use X-Robots-Tag: noindex for archives you want to keep, and leave robots.txt allowing them
  • Request a real static asset and read its Cache-Control before adding a custom header rule
  • Confirm which bundler your build uses before tuning its config
  • Run Lighthouse in light and dark on every page, since a sample can miss a pattern that most pages share
  • Add a skip link, and test the keyboard path through any menu or dialog by hand
  • Re-run any "all clear" check once against something you know is broken

Lab scores to compare (live, mobile): accessibility 91–96 to 100 (all 28 routes), performance 97 to 97, LCP 2.4–2.6 s to 2.4 s.


#On this site

PostWhy
Four generations of my personal site, from Gatsby to Next.jsThe old versions this audit kept out of the index
Triaging Shai-Hulud and npm audit on Next.jsSupply-chain checks on the same repository
Cutting a data API from 21s to ~250msSame habit on a backend: measure first, then change one thing

#References (curated)

ReferenceNotes
Block search indexing with noindexWhy a page blocked by robots.txt cannot have its noindex read.
Lighthouse scoring calculatorPlug in LCP, FCP, TBT and CLS to see how metrics map to a score.
Lighthouse throttlingSimulated versus observed throttling, which explains the local-versus-live gap.
Largest Contentful PaintWhat the metric measures and the usual causes.
Turbopack in Next.jsWhy webpack-only config stops applying.
WCAG 1.4.3 Contrast (Minimum)The 4.5:1 rule for normal text.
WCAG 2.5.3 Label in NameWhy a visible label must appear in the accessible name.
WCAG 2.5.8 Target Size (Minimum)The 24 px target rule.
FAQ rich resultsCurrent eligibility for FAQ structured data.