Skip to content

Two Ahrefs Findings on My Astro Sites, Fixed

Published: at 09:00 PM

Ahrefs audits this blog and tawadi.de. This week it flagged two things I would not have noticed myself. Both turned out to be small fixes once I knew the cause.

Ahrefs listed 22 pages of this blog with that error. The pages existed, they were in the sitemap, and I could click my way to them. So where were the links?

The canonical URLs end in a slash: /posts/2/, /impressum/. Many of my links did not:

The CDN answers both forms, so nothing looked broken in the browser. For a crawler, /posts/2 and /posts/2/ are two URLs. The link points at one, the canonical at the other. From Ahrefs’ point of view the canonical page had no links at all.

The fix for the generated links is one line in astro.config.ts:

export default defineConfig({
  site: SITE.website,
  // Canonicals and the sitemap use the slash form; make paginate() links match.
  trailingSlash: "always",
  // ...
});

With that, paginate() builds url.next and url.prev with a slash. The hand-written links in the footer, the archive and the post layout I changed by hand.

Checking it on the build, not in the audit

I did not want to wait for the next crawl to know whether it worked. A short script walks dist/, collects every internal href, and checks two rules:

  1. every link matches a built page exactly as written
  2. every URL in the sitemap gets at least one link from another page
for (const m of readFileSync(f, "utf8").matchAll(
  /<a\b[^>]*?\shref=["']?([^"'\s>]+)/g
)) {
  let h = m[1];
  if (h.startsWith(SITE)) h = h.slice(SITE.length) || "/";
  if (!h.startsWith("/") || h.startsWith("//")) continue;
  h = h.split("#")[0].split("?")[0];
  if (!h || /\.[a-z0-9]+$/i.test(h)) continue; // rss.xml, images
  if (pages.has(h)) inlinks.set(h, (inlinks.get(h) ?? 0) + 1);
  else bad.add(h);
}

Before the fix: 108 links that did not match a page, 22 sitemap URLs without a single link. Same 22 as Ahrefs. After: 0 and 0.

The redirects pointed nowhere

The script found one more thing. This blog started on WordPress, so I keep 34 redirects from the old /index.php/2011/... URLs. They all pointed to /posts/YYYY-MM-25-.... None of those URLs existed, so every old link from 2011 ended on a 404. They now point to the real posts.

Astro redirects are not 301s

While checking those redirects I looked at what Astro actually serves:

$ curl -s -o /dev/null -w "%{http_code}\n" https://blog.wlami.com/index.php/2011/09/petition-...
200

The config says status: 301. On a static build Astro cannot send a status code, so it writes an HTML page with a <meta http-equiv="refresh"> and rel="canonical". Google follows these, but they are not real redirects. If you host static and care about it, do the redirect on the CDN.

Press screenshots: 2 MB per PNG

The second finding was on tawadi.de. The press page for my app offers the App Store screenshots for download, 12 PNGs at the full 1320x2868. Ahrefs flagged them as too big: up to 1.84 MB each.

They sat in public/, which Astro copies as-is. I moved them to src/assets/ and let Astro’s built-in image pipeline handle them:

---
import { getImage } from "astro:assets";
import type { ImageMetadata } from "astro";

const sources = import.meta.glob<{ default: ImageMetadata }>(
  "../../assets/tidyroom/press/*/*.png",
  { eager: true }
);

const download = async (lang: string, f: string) => ({
  href: (
    await getImage({
      src: sources[`../../assets/tidyroom/press/${lang}/${f}.png`].default,
      format: "png",
      quality: 90,
    })
  ).src,
  name: `tidyroom-${lang}-${f}.png`,
});
---

Two details:

The built files get hashed names under /_astro/, which nobody wants in their downloads folder. The download attribute fixes that:

<a href={en.href} download={en.name}>EN</a>

The old URLs under /press/tidyroom/screenshots/ were public, so they get a real 301 on Bunny CDN. That site’s CDN config is in Pulumi, same as my DNS:

_, err = bunnynet.NewPullzoneEdgerule(ctx, "press-screenshots-moved", &bunnynet.PullzoneEdgeruleArgs{
	Pullzone:         pz.PullzoneId,
	Enabled:          pulumi.Bool(true),
	Action:           pulumi.String("Redirect"),
	ActionParameter1: pulumi.String("https://" + apex + "/tidyroom/press"),
	ActionParameter2: pulumi.String("301"),
	MatchType:        pulumi.String("MatchAny"),
	Triggers:         urls("https://" + apex + "/press/tidyroom/screenshots/*"),
})
$ curl -sI https://tawadi.de/press/tidyroom/screenshots/en/01-home.png
HTTP/2 301
location: https://tawadi.de/tidyroom/press

What I take from it


Next Post
Managing App Store Connect with Pulumi