Files
skillhub/apps/web/app/sitemap.ts
airano 2d37a5c16e fix(seo): generate correct sitemap URLs for default locale
Sitemap was generating /en/* URLs which redirect to unprefixed paths
due to localePrefix: 'as-needed', causing Google Search Console
"Page with redirect" warnings and wasting crawl budget.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-15 17:27:16 +03:30

140 lines
3.5 KiB
TypeScript

import type { MetadataRoute } from 'next';
import { createDb, skillQueries, categoryQueries } from '@skillhub/db';
const BASE_URL =
process.env.NEXT_PUBLIC_APP_URL || 'https://skills.palebluedot.live';
const locales = ['en', 'fa'] as const;
const DEFAULT_LOCALE = 'en';
/** Get canonical URL path for a locale (no prefix for default locale, matching localePrefix: 'as-needed') */
function canonicalPath(locale: string, route: string): string {
if (locale === DEFAULT_LOCALE) return route || '/';
return `/${locale}${route}`;
}
const staticRoutes = [
'',
'/browse',
'/categories',
'/featured',
'/new',
'/docs',
'/docs/getting-started',
'/docs/cli',
'/docs/api',
'/about',
'/attribution',
'/claude-plugin',
'/claim',
'/contact',
'/support',
'/terms',
'/privacy',
];
function makeEntry(
locale: string,
route: string,
options?: {
lastModified?: Date;
changeFrequency?: MetadataRoute.Sitemap[number]['changeFrequency'];
priority?: number;
}
): MetadataRoute.Sitemap[number] {
return {
url: `${BASE_URL}${canonicalPath(locale, route)}`,
lastModified: options?.lastModified,
changeFrequency: options?.changeFrequency,
priority: options?.priority,
alternates: {
languages: Object.fromEntries(
locales.map((l) => [l, `${BASE_URL}${canonicalPath(l, route)}`])
),
},
};
}
export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
// Mirror servers should not serve a sitemap (robots.txt already blocks crawlers)
const isPrimary = process.env.IS_PRIMARY_SERVER !== 'false';
if (!isPrimary) {
return [];
}
const db = createDb();
const entries: MetadataRoute.Sitemap = [];
// Static pages (for each locale)
for (const locale of locales) {
for (const route of staticRoutes) {
const isHome = route === '';
entries.push(
makeEntry(locale, route, {
changeFrequency: isHome ? 'daily' : 'weekly',
priority: isHome ? 1.0 : 0.5,
})
);
}
}
// Dynamic: skill pages
try {
const allSkills = await skillQueries.getAllForSitemap(db);
for (const skill of allSkills) {
for (const locale of locales) {
entries.push(
makeEntry(locale, `/skill/${skill.id}`, {
lastModified: skill.updatedAt,
changeFrequency: 'weekly',
priority: 0.7,
})
);
}
}
// Dynamic: owner pages (unique owners from skills)
const uniqueOwners = new Map<string, Date>();
for (const skill of allSkills) {
const existing = uniqueOwners.get(skill.githubOwner);
if (!existing || skill.updatedAt > existing) {
uniqueOwners.set(skill.githubOwner, skill.updatedAt);
}
}
for (const [owner, lastModified] of uniqueOwners) {
for (const locale of locales) {
entries.push(
makeEntry(locale, `/owner/${owner}`, {
lastModified,
changeFrequency: 'weekly',
priority: 0.6,
})
);
}
}
} catch {
// If DB is unavailable, return static pages only
}
// Dynamic: category pages
try {
const allCategories = await categoryQueries.getAll(db);
for (const category of allCategories) {
for (const locale of locales) {
entries.push(
makeEntry(locale, `/browse?category=${category.slug}`, {
changeFrequency: 'weekly',
priority: 0.5,
})
);
}
}
} catch {
// If DB is unavailable, skip categories
}
return entries;
}