Astro Redirect Rules for Static Hosting (_redirects + astro.config)
301 redirects from old URLs, trailing slash normalization, and when to use redirects vs getStaticPaths.
Tested on: Astro 5.7, Netlify + Cloudflare Pages
Scenario: Migrated from /posts/slug to /blog/slug. 40 old URLs needed 301s.
Option 1 — hosting _redirects (my choice)
public/_redirects:
/posts/* /blog/:splat 301
/writing /blog/ 301
/rss.xml /blog/rss.xml 301
/old-about /about/ 301
Works on Netlify and Cloudflare Pages. Files copy to dist/_redirects on build.
Option 2 — astro.config redirects (build-time)
export default defineConfig({
redirects: {
'/posts/[...slug]': '/blog/[...slug]',
'/writing': '/blog'
}
});
Astro generates HTML meta-refresh or hosting-specific files depending on adapter.
For pure static (no adapter), redirects in config creates HTML redirect pages — works but slower than server 301.
Option 3 — getStaticPaths alias (content migration)
Temporary dual URLs during transition:
---
// src/pages/posts/[slug].astro
import { getCollection } from 'astro:content';
export async function getStaticPaths() {
const posts = await getCollection('blog');
return posts.map((p) => ({
params: { slug: p.slug },
props: { redirect: `/blog/${p.slug}/` }
}));
}
const { redirect } = Astro.props;
---
<meta http-equiv="refresh" content={`0;url=${redirect}`} />
<link rel="canonical" href={redirect} />
Prefer 301 via _redirects for SEO. Meta refresh is fallback.
Trailing slash normalization
astro.config.mjs:
export default defineConfig({
trailingSlash: 'always' // or 'never' — pick one
});
_redirects complement:
/blog /blog/ 301
/about /about/ 301
Error — redirect loop
/blog/ /blog 301
/blog /blog/ 301
Pick one trailing slash style. Two rules fighting = infinite loop.
Cloudflare _redirects syntax difference
Cloudflare supports:
/posts/:slug /blog/:slug 301
Netlify uses :splat for wildcards. Test on your host.
Verify all 40 redirects
scripts/check-redirects.mjs:
const rules = [
['/posts/hello-world', '/blog/hello-world'],
['/writing', '/blog']
];
const base = 'https://myblog.dev';
for (const [from, expectedLocation] of rules) {
const res = await fetch(`${base}${from}`, { redirect: 'manual' });
const loc = res.headers.get('location');
console.log(from, res.status, loc === expectedLocation || loc === expectedLocation + '/' ? 'OK' : `FAIL: ${loc}`);
}
Google Search Console
After deploy, submit updated sitemap. Old URLs show "Page with redirect" — expected. Rankings transfer in 2-4 weeks.
Result
40 redirects deployed via _redirects
0 redirect loops
Build time impact: 0s (hosting-level)
GSC crawl errors: 38 → 0 in 10 days