Redirects
Redirects are added from Sanity. There is a schema type called redirects that contains the information needed for them to work:
import {text} from '@island/text.js'import {option} from '@island/option'export default { name: 'redirects', title: 'Redirects', type: 'document', fields: [ ...text({name: 'from', title: 'From', rows: 3, localized: false, options: {aiAssist: {exclude: true}}}), ...text({name: 'to', title: 'To', localized: false, options: {aiAssist: {exclude: true}}}), ...option({ title: 'Choose Redirect Type', name: 'type', description: 'Choose between a permanent or temporary redirect', localized: false, layout: 'radio', direction: 'horizontal', initialValue: 'permanent', list: [ {value: 'permanent', title: 'Permanent (301)'}, {value: 'temporary', title: 'Temporary (302)'}, ], }), ], preview: { select: { title: 'from', subtitle: 'to', }, prepare(selection) { return { title: selection.title, subtitle: selection.subtitle, } }, },}All of this information is fed into Astro via query:
export async function getRedirects() { const query = ` *[_type == 'redirects']{ to, from, type }`;
const data = await sanityClient.fetch(query);
return data;}And we process that data to send into Astro so it can process the redirections:
export async function buildRedirects() { const redirectsMap = {}; const data = await getRedirects();
if (!data?.length) return redirectsMap;
for (const { from, to, type } of data) { if (!from || !to) continue;
let key; try { const trimmed = from.trim();
// Check if this is a wildcard pattern - preserve it as-is if (isWildcardPattern(trimmed)) { // For wildcard patterns, just normalize the path without URL parsing // This preserves * and :lang syntax key = trimmed.startsWith("/") ? trimmed : `/${trimmed}`; // Remove trailing slash except for root if (key.length > 1 && key.endsWith("/")) key = key.slice(0, -1); } else { // For regular redirects, parse the URL to extract pathname const parsedUrl = trimmed.startsWith("http") ? new URL(trimmed) : new URL(trimmed, "http://_");
// Extract pathname (e.g. "/page.html") key = parsedUrl.pathname; if (key.length > 1 && key.endsWith("/")) key = key.slice(0, -1); } } catch (err) { console.warn(`[redirects] Skipping invalid 'from' URL: ${from}`); continue; } const redirectStatus = type === "temporary" ? 302 : 301;
redirectsMap[key] = { status: redirectStatus, destination: to.trim(), preserveQuery: true, }; }
return redirectsMap;}So we get the URLs, add http if missing, and build the redirection map to send to our edge function.
We changed from Astro built-in redirections to an Edge function that handles them because of case sensitivity issues. Since Astro’s built-in redirections are case sensitive by default and we needed to not be, we decided to change into our Edge function.
This also handles the issue we had with having to build every time anyone uploaded a redirection to Sanity.
To avoid making so many calls to the Sanity API (since a call would be made every time we called our URL if the redirects are processed directly in the edge function) we use Netlify’s Blobs. They allow us to store our redirect list and we update this list every ten minutes using our sync-redirects.js serverless function. The cronjob is set to ten minutes inside our netlify.toml file, you can change it if needed.
We’ve also added wildcard pattern support to make internal redirections, since we need to be able to add one redirect that is valid for all languages.
So adding FROM / * /test TO / * /test-1 for instance would add a redirection from /en/test to /en/test-1, /es/test to /es/test-1, etc.
This is used mostly for slug changes and / or mistakes in slug assignment that need to be corrected afterwards when the malformed URL is already linked to, or for resources moving, for instance, we have several blogs that were moved into the cybersecurity glossary.
All of the redirections are controlled by our redirects.ts edge function, that runs on every call, just like our language redirection edge function.
We have all of our wildcard-related helper functions:
// detect if there is a wildcard presentfunction isWildcardPattern(path: string): boolean {...}
// convert to regexfunction patternToRegex(pattern: string): { regex: RegExp; captureNames: string[] } { ...}
// replace placeholders like :lang or :splatfunction replaceCaptures( destination: string, captures: string[], captureNames: string[]): string { ...}
// build the patterns from the redirects mapfunction buildWildcardPatterns(redirectsMap: RedirectMap): Array<{ pattern: RegExp; config: RedirectConfig; originalFrom: string; captureNames: string[];}> { ...}
// find wildcard matchesfunction findWildcardMatch( path: string, patterns: Array<{ pattern: RegExp; config: RedirectConfig; originalFrom: string; captureNames: string[]; }>): { config: RedirectConfig; captures: string[]; captureNames: string[] } | null { ...}And our blob-store related functions:
// access Netlify's blob storefunction getBlobStore() {...}
// gets redirects from the blob store or from Sanity as a fallbackasync function getRedirects(): Promise<RedirectMap> {...}And finally our redirects function that uses all of them to build our redirects with wildcard support from the blob store:
export default async function redirects(request: Request, context: Context) { const url = new URL(request.url); let path = url.pathname.toLowerCase();
// Normalize path by removing trailing slash (except for root "/") if (path.length > 1 && path.endsWith("/")) { path = path.slice(0, -1); }
const redirectsMap = await getRedirects();
// 1. First, try exact match (fastest) const exactEntry = redirectsMap[path];
if (exactEntry && !isWildcardPattern(path)) { let destination = exactEntry.destination;
if (exactEntry.preserveQuery && url.search) { const destUrl = new URL(destination, url.origin); if (!destUrl.search) { destUrl.search = url.search; } destination = destUrl.toString(); }
// Netlify Edge: use a Redirect response return Response.redirect(destination, exactEntry.status); }
// 2. Try wildcard pattern matching // Build wildcard patterns (rebuild when redirects cache is refreshed) if (!cachedWildcardPatterns || wildcardPatternsTimestamp !== cacheTimestamp) { cachedWildcardPatterns = buildWildcardPatterns(redirectsMap); wildcardPatternsTimestamp = cacheTimestamp; }
if (cachedWildcardPatterns.length > 0) { const wildcardMatch = findWildcardMatch(path, cachedWildcardPatterns);
if (wildcardMatch) { const { config, captures, captureNames } = wildcardMatch;
// Replace wildcards/captures in destination with actual values let destination = replaceCaptures(config.destination, captures, captureNames);
if (config.preserveQuery && url.search) { const destUrl = new URL(destination, url.origin); if (!destUrl.search) { destUrl.search = url.search; } destination = destUrl.toString(); }
console.log(`[redirects] Wildcard match: ${path} -> ${destination}`); return Response.redirect(destination, config.status); } }
// Continue to next handler (Astro, static file, etc.) return context.next();}