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.
”Canonical URL has no incoming internal links”
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:
paginate()generated/posts/2for the next and previous page links- the footer linked
/impressumand/datenschutz - the archive page and the prev/next links under each post had no slash
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:
- every link matches a built page exactly as written
- 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:
format: "png"keeps PNG. Journalists want PNG, not WebP.qualityon a PNG makes sharp write a palette PNG. Screenshots with flat UI colors lose nothing you can see, and the largest file went from 1.84 MB to 654 KB.
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
- Pick one URL form and use it everywhere: canonicals, sitemap, and every
link.
trailingSlashmakes Astro enforce it for generated links. - Check links on the build. The script runs in a second; the audit only runs when the crawler comes by.
- Files in
public/skip every optimization. If a file is meant for download, it still goes throughastro:assets. - A redirect in a static Astro config is a meta refresh. Real 301s belong on the CDN.