Hreflang Tags in Next.js: How We Ship 30 Languages Across 118 Pages
Let me share a number that kept me up at night: 3,540. That's 30 languages multiplied by 118 pages. If we'd launched all of those simultaneously on Deluxe Astrology, Google would've indexed thousands of thin, untranslated, or machine-garbage pages. Our rankings would've cratered. Instead, we built a two-tier translation gating system that rolls out languages progressively, uses Claude Haiku for batch translation at roughly $22 per language, and gates quality with Winston AI scoring. The whole thing runs on Next.js with next-intl middleware, locale-aware canonical URLs, and hreflang tags in both the HTML head and XML sitemaps. This is the full breakdown of how we did it -- every middleware config, every sitemap entry, every cost calculation.

Why Hreflang Tags Still Matter in 2025
Google's language detection has gotten better. I'll give them that. But "better" doesn't mean "solved." If you're running regional variants -- think pt-BR vs pt-PT, or zh-CN vs zh-TW -- Google still needs explicit signals. Without hreflang, you'll see your Portuguese pages from Brazil cannibalizing your Portugal-targeted content, and vice versa.
Here's what the data tells us:
- Over 60% of multilingual websites have hreflang configuration errors (source: Ahrefs studies on international SEO audits)
- Proper hreflang implementation can lift click-through rates by 20-30% in targeted markets within 4-6 weeks
- Sites without hreflang experience unpredictable ranking rotations between language versions, making performance tracking nearly impossible
For Deluxe Astrology, we're targeting 30 languages with distinct content. Not regional variants -- actual different languages. That's 30 different audiences who need to find the right version in search results. Hreflang isn't optional here. It's the foundation.
The thing most guides miss: you need hreflang in both the HTML <head> and your XML sitemap. Not one or the other. Both. Google has confirmed they process hreflang from multiple sources, and redundancy here isn't waste -- it's insurance.
The 3,540 Page Problem
Let me walk through the math that shaped our entire architecture.
Deluxe Astrology has:
- 118 pages (core content pages)
- 41 translation namespaces (logical groupings of translatable strings)
- 39 locale-aware API routes
- 30 target languages
30 × 118 = 3,540 total page variants.
If we launched all 3,540 pages on day one, here's what would happen:
- Most pages would contain English fallback text with a non-English URL path. Google sees this as thin/duplicate content.
- Googlebot would burn crawl budget indexing thousands of low-quality pages.
- The site's overall quality signal would tank, dragging down even the good English pages.
- Users landing on untranslated pages would bounce immediately.
This isn't theoretical. I've seen it happen on client sites that plugged in Weglot or similar tools and flipped the switch for 20 languages overnight. Traffic went down, not up.
The solution: don't launch all languages at once. Gate them.
Two-Tier Translation Gating System
We split our 30 languages into two tiers with fundamentally different launch strategies.
Tier 1: TRANSLATED_LOCALES
These are 15 languages with fully translated core pages, manually reviewed by native speakers or verified bilinguals.
// config/locales.ts
export const TRANSLATED_LOCALES = [
'en', 'es', 'fr', 'de', 'it', 'pt-BR', 'ja', 'ko',
'zh-CN', 'zh-TW', 'ru', 'ar', 'hi', 'tr', 'nl'
] as const;
These 15 languages get:
- Full hreflang tags across all 118 pages
- Inclusion in the XML sitemap
- Indexable, canonical URLs
- Locale-aware schema markup
Tier 2: DYNAMIC_TRANSLATED_LOCALES
The remaining 15 languages start as English-only placeholders. They're not indexed. They don't get hreflang entries. They don't exist in the sitemap.
export const DYNAMIC_TRANSLATED_LOCALES = [
'pl', 'sv', 'da', 'fi', 'no', 'cs', 'ro', 'hu',
'el', 'th', 'vi', 'id', 'ms', 'uk', 'bg'
] as const;
export const ALL_LOCALES = [
...TRANSLATED_LOCALES,
...DYNAMIC_TRANSLATED_LOCALES
] as const;
When a Tier 2 language completes the translation pipeline -- Claude Haiku batch translation, Winston AI quality gate, optional human review -- it graduates to Tier 1. The hreflang entries, sitemap inclusion, and indexing directives update automatically.
// utils/locale-status.ts
export function isLocaleReady(locale: string): boolean {
// Check if all required namespaces have translations
// with Winston AI scores >= 95%
const status = getTranslationStatus(locale);
return status.completedNamespaces >= REQUIRED_NAMESPACES
&& status.minQualityScore >= 0.95;
}
export function getIndexableLocales(): string[] {
return ALL_LOCALES.filter(isLocaleReady);
}
This is the key insight: your hreflang implementation needs to be dynamic. It can't be a static list hardcoded at build time (well, it can if you rebuild when locales graduate, which is what we do with ISR).

Next-intl Middleware Configuration
The middleware is where locale detection, routing, and the gating logic all converge. Here's our actual middleware.ts:
// middleware.ts
import createMiddleware from 'next-intl/middleware';
import { NextRequest, NextResponse } from 'next/server';
import { ALL_LOCALES, TRANSLATED_LOCALES } from './config/locales';
const intlMiddleware = createMiddleware({
locales: ALL_LOCALES,
defaultLocale: 'en',
localePrefix: 'always',
localeDetection: true
});
export default function middleware(request: NextRequest) {
const pathname = request.nextUrl.pathname;
// Extract locale from path
const pathnameLocale = ALL_LOCALES.find(
(locale) => pathname.startsWith(`/${locale}/`) || pathname === `/${locale}`
);
// If locale is in Tier 2 and not yet ready,
// serve content but add noindex header
if (
pathnameLocale &&
!TRANSLATED_LOCALES.includes(pathnameLocale) &&
!isLocaleReady(pathnameLocale)
) {
const response = intlMiddleware(request);
response.headers.set('X-Robots-Tag', 'noindex, nofollow');
return response;
}
return intlMiddleware(request);
}
export const config = {
matcher: ['/((?!api|_next|_vercel|.*\\..*).*)']
};
A few things to note here:
localePrefix: 'always'-- Every URL gets a locale prefix./en/horoscope,/de/horoskop, etc. No ambiguity. This is critical for hreflang because every alternate URL must be distinct and predictable.Tier 2 noindex -- Untranslated locales still render (users from those regions can still browse), but they get a
noindexheader. Google won't waste crawl budget on them.The matcher -- We exclude API routes, Next.js internals, and static files. The 39 locale-aware API routes have their own locale handling.
If you're building something similar, we've written more about our Next.js development approach and how middleware fits into the architecture.
Hreflang Implementation in HTML Head
Next.js 14+ with the App Router gives us the generateMetadata function. This is where hreflang tags go in the HTML <head>.
// app/[locale]/[...slug]/page.tsx
import { getIndexableLocales } from '@/utils/locale-status';
import { getLocalizedSlug } from '@/utils/slugs';
type Props = {
params: { locale: string; slug: string[] };
};
export async function generateMetadata({ params }: Props): Promise<Metadata> {
const { locale, slug } = params;
const baseUrl = 'https://deluxeastrology.com';
const pagePath = slug ? `/${slug.join('/')}` : '';
const indexableLocales = getIndexableLocales();
// Build language alternates -- only for indexable locales
const languages: Record<string, string> = {};
for (const loc of indexableLocales) {
const localizedSlug = await getLocalizedSlug(pagePath, loc);
languages[loc] = `${baseUrl}/${loc}${localizedSlug}`;
}
// x-default points to English
languages['x-default'] = `${baseUrl}/en${pagePath}`;
return {
title: await getLocalizedTitle(pagePath, locale),
alternates: {
canonical: `${baseUrl}/${locale}${pagePath}`,
languages
}
};
}
This generates HTML like:
<link rel="canonical" href="https://deluxeastrology.com/de/horoskop" />
<link rel="alternate" hreflang="en" href="https://deluxeastrology.com/en/horoscope" />
<link rel="alternate" hreflang="de" href="https://deluxeastrology.com/de/horoskop" />
<link rel="alternate" hreflang="fr" href="https://deluxeastrology.com/fr/horoscope" />
<!-- ... 12 more indexable locales ... -->
<link rel="alternate" hreflang="x-default" href="https://deluxeastrology.com/en/horoscope" />
Two critical details:
- The canonical URL is locale-specific. The German page's canonical is the German URL, not the English one. Each language version is its own canonical page.
- x-default is always present. It points to English. If Google can't match a user's language to any of your hreflang entries, x-default is the fallback.
Sitemap Generation with Hreflang Entries
HTML <head> hreflang is necessary but not sufficient. For a site with 3,540 potential page variants, you also need hreflang in your XML sitemap. Here's why: Google can discover hreflang relationships from the sitemap without crawling every page first.
// app/sitemap.ts
import { MetadataRoute } from 'next';
import { getIndexableLocales } from '@/utils/locale-status';
import { getAllPages } from '@/utils/pages';
import { getLocalizedSlug } from '@/utils/slugs';
export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
const baseUrl = 'https://deluxeastrology.com';
const indexableLocales = getIndexableLocales();
const pages = await getAllPages(); // Returns 118 page definitions
const entries: MetadataRoute.Sitemap = [];
for (const page of pages) {
for (const locale of indexableLocales) {
const localizedSlug = await getLocalizedSlug(page.path, locale);
const url = `${baseUrl}/${locale}${localizedSlug}`;
// Build alternates for this specific page
const alternates: Record<string, string> = {};
for (const altLocale of indexableLocales) {
const altSlug = await getLocalizedSlug(page.path, altLocale);
alternates[altLocale] = `${baseUrl}/${altLocale}${altSlug}`;
}
alternates['x-default'] = `${baseUrl}/en${page.path}`;
entries.push({
url,
lastModified: page.updatedAt,
changeFrequency: page.changeFreq || 'weekly',
priority: locale === 'en' ? 0.9 : 0.8,
alternates: {
languages: alternates
}
});
}
}
return entries;
}
This generates XML like:
<url>
<loc>https://deluxeastrology.com/de/horoskop</loc>
<lastmod>2025-01-15</lastmod>
<xhtml:link rel="alternate" hreflang="en" href="https://deluxeastrology.com/en/horoscope"/>
<xhtml:link rel="alternate" hreflang="de" href="https://deluxeastrology.com/de/horoskop"/>
<xhtml:link rel="alternate" hreflang="fr" href="https://deluxeastrology.com/fr/horoscope"/>
<xhtml:link rel="alternate" hreflang="x-default" href="https://deluxeastrology.com/en/horoscope"/>
</url>
With 15 indexable locales and 118 pages, that's 1,770 sitemap entries. Manageable. When all 30 languages are ready, it'll be 3,540. Still within Google's 50,000-URL sitemap limit, but we split into per-locale sitemaps anyway for cleaner Google Search Console monitoring.
Translation Pipeline: Claude Haiku + Winston AI
Here's where the economics get interesting. We needed to translate 118 pages across 41 namespaces into 30 languages. Professional human translation would be the gold standard, but the budget math is brutal.
The Pipeline
- Extract -- Pull all translatable strings from 41 namespaces into structured JSON
- Translate -- Batch process through Claude Haiku (Anthropic's fast, cheap model) with context about the domain (astrology), tone, and target audience
- Quality Gate -- Run translated content through Winston AI's content detection and quality scoring. Threshold: 95%+ or reject.
- Human Review -- High-value pages (homepage, landing pages, money pages) get manual review by native speakers
- Graduate -- Once all namespaces pass quality gates, the locale moves from
DYNAMIC_TRANSLATED_LOCALEStoTRANSLATED_LOCALES
// scripts/translate-locale.ts
async function translateLocale(targetLocale: string) {
const namespaces = await getNamespaces(); // 41 namespaces
for (const ns of namespaces) {
const sourceStrings = await loadNamespace('en', ns);
const translated = await claude.messages.create({
model: 'claude-3-haiku-20240307',
max_tokens: 4096,
system: `You are a professional translator specializing in astrology content.
Translate from English to ${getLanguageName(targetLocale)}.
Maintain astrological terminology accuracy.
Preserve all interpolation variables like {name} and {date}.`,
messages: [{
role: 'user',
content: `Translate these JSON key-value pairs. Return valid JSON only:\n${JSON.stringify(sourceStrings, null, 2)}`
}]
});
const qualityScore = await winstonAI.analyze(translated.content);
if (qualityScore >= 0.95) {
await saveNamespace(targetLocale, ns, translated.content);
} else {
await flagForReview(targetLocale, ns, translated.content, qualityScore);
}
}
}
The per-language cost with Claude Haiku works out to approximately $22 for all 118 pages across 41 namespaces. That's mostly token costs -- Haiku is incredibly cheap at $0.25 per million input tokens and $1.25 per million output tokens (2025 pricing).
Cost Comparison: Our Approach vs Alternatives
This is the table that convinced the Deluxe Astrology team:
| Approach | Cost for 30 Languages | Ongoing Cost | Quality | Time to Launch |
|---|---|---|---|---|
| Claude Haiku + Winston AI | ~$660 total ($22/lang) | $0 (one-time) | 95%+ quality gate, human review for key pages | 2-3 weeks rolling |
| Weglot | $0 setup | $699/month ($8,388/year) | Machine translation, editable | Instant but risky |
| Professional Translators | $150K-$300K ($5K-10K/lang) | $2K-5K/lang for updates | Highest quality | 3-6 months |
| DeepL API | ~$400 total | $0 (one-time) | Good but no quality gate | 1-2 weeks |
| Google Translate API | ~$300 total | $0 (one-time) | Lower quality for niche content | 1 week |
Let's be honest: the $660 total for Claude Haiku translation of 30 languages is almost suspiciously cheap. The catch is that you need the quality gate (Winston AI) and human review layer to make it production-ready. Even with those costs factored in -- maybe $50-100 for Winston AI API calls and $500-1,000 for human review of high-value pages -- you're still under $2,000 total. Compare that to Weglot's $699/month. You'd break even in under 3 months.
The real killer with Weglot and similar services: they translate everything at once. No gating. No quality control per page. You flip a switch and suddenly Google sees 3,540 pages, many of which are mediocre machine translations. Our approach lets us be surgical about it.
We talk more about how we approach projects like this on our pricing page -- the translation pipeline is one component of a larger headless CMS development architecture.
Locale-Aware Schema Markup
This one catches almost everyone off guard. Your structured data needs to match the page language. A German page with English FAQ schema confuses Google's understanding of the page.
// utils/schema.ts
export function generateFAQSchema(
faqs: Array<{ question: string; answer: string }>,
locale: string
) {
return {
'@context': 'https://schema.org',
'@type': 'FAQPage',
'inLanguage': locale, // Critical: must match page locale
'mainEntity': faqs.map((faq) => ({
'@type': 'Question',
'name': faq.question, // Must be in target language
'acceptedAnswer': {
'@type': 'Answer',
'text': faq.answer // Must be in target language
}
}))
};
}
Every schema type that supports inLanguage should use it. For Deluxe Astrology, that includes:
- FAQPage -- Questions and answers in the target language
- Article --
inLanguagematching the locale - WebPage --
inLanguageproperty - BreadcrumbList -- Breadcrumb names in the target language
Don't just translate the visible content and forget the structured data. Google reads both.
Common Mistakes That Will Tank Your Rankings
Missing x-default hreflang
I see this constantly. Sites implement hreflang for all their languages but forget x-default. Without it, Google has no fallback for users whose language doesn't match any of your versions. Always include it. Always point it at your primary language (usually English).
Inconsistent locale in URL vs content
If your URL says /fr/horoscope but the page content is in English because the translation hasn't loaded or fallen back, Google will flag this as a soft 404 or thin content. This is exactly why we built the two-tier gating system -- a page doesn't get a French URL until it has French content.
Launching all languages at once
I've beaten this drum already, but it bears repeating. Launching 30 languages simultaneously is the single most common mistake in international SEO. Even if your translations are perfect, you're asking Google to crawl, index, and evaluate thousands of new pages overnight. Roll them out in batches of 3-5 languages. Monitor indexing in GSC. Then add more.
Non-reciprocal hreflang tags
If page A (English) points to page B (German) via hreflang, page B must point back to page A. If this reciprocal link is missing, Google ignores the hreflang entirely. When you're generating these dynamically (as we do), reciprocity is automatic. But if you're managing them manually, audit regularly.
Self-referencing hreflang missing
Every page must include itself in its own hreflang set. The German page must list hreflang="de" pointing to itself. This is easy to miss in manual implementations.
Hreflang in only one location
Putting hreflang only in the <head> or only in the sitemap is a mistake. Use both. Belt and suspenders. Google processes both sources, and if one fails to get crawled, the other serves as backup.
For projects at this scale, having an experienced team helps avoid these pitfalls. If you're planning a multilingual build, we're happy to talk through the approach.
FAQ
Do I need hreflang tags if I only have language differences (not regional)?
Yes. While Google's language detection has improved in 2025, hreflang is still the definitive signal for telling search engines which language version to serve. Without them, you risk Google showing your English page to French-speaking users simply because the English version has more backlinks. Hreflang becomes even more critical when you have 10+ languages -- the probability of cross-language cannibalization increases dramatically with scale.
How many hreflang entries are too many for a single page?
Google hasn't published an official limit, but practical testing shows that beyond 50 language variants per page, you start seeing diminishing returns and occasional parsing issues. For our 30-language setup, each page has 31 hreflang entries (30 languages + x-default), which is well within the safe zone. If you're dealing with 50+ regional and language combinations, consider using only the XML sitemap approach to keep <head> size manageable.
Should I use hreflang in the HTML head, XML sitemap, or HTTP headers?
For Next.js applications, use both HTML <head> and XML sitemap. HTTP headers are primarily useful for non-HTML resources like PDFs. The HTML <head> approach is processed at crawl time and gives the fastest signal. The sitemap acts as a backup and helps Google discover alternate pages it hasn't crawled yet. We don't recommend relying on just one method.
What's the cost of translating a full website with AI in 2025?
Using Claude Haiku, we translate 118 pages across 41 namespaces for approximately $22 per language. For 30 languages, that's about $660 total. Add Winston AI quality gating at roughly $50-100 for API calls, and optional human review for high-value pages at $500-1,000, and your all-in cost is under $2,000. Compare this to Weglot at $699/month or professional translation services at $5,000-10,000 per language.
Why use a two-tier translation gating system instead of translating everything at once?
Google treats thin content as a negative quality signal that can drag down your entire domain. If you launch 30 languages but only 15 have quality translations, those 15 poorly-translated languages create roughly 1,770 low-quality pages. The two-tier system ensures that only pages meeting a 95%+ quality threshold get indexed. Languages graduate from Tier 2 to Tier 1 as translations pass quality gates, protecting your domain authority throughout the rollout.
How do I handle untranslated pages for a locale that's partially translated?
For locales where some namespaces are translated but others aren't, we fall back to English content and add a noindex meta tag via middleware. The URL still resolves (users can access it), but Google won't index the mixed-language page. Once all required namespaces pass quality gates, the noindex tag is removed and hreflang entries are added. This prevents partial translations from polluting your index.
What quality score threshold should I use for AI translations?
We use Winston AI with a 95%+ quality score threshold. Anything below gets flagged for human review or re-translation with adjusted prompts. In practice, Claude Haiku achieves 95%+ on about 85% of namespace batches on the first pass. The remaining 15% typically fail due to domain-specific terminology (astrology terms that don't directly translate) or complex sentence structures. A 90% threshold would let through noticeably awkward phrasing.
Can I use Astro instead of Next.js for multilingual sites with hreflang?
Absolutely. Astro has excellent i18n support built in as of Astro 4.0+, and its static generation model actually simplifies hreflang implementation since all URLs are known at build time. We've built multilingual projects with both frameworks. For sites with heavy dynamic content and API routes (like Deluxe Astrology's 39 locale-aware endpoints), Next.js is the better fit. For content-heavy sites with less interactivity, Astro development can be faster and more performant. The hreflang principles are identical regardless of framework.