
Hreflang in SvelteKit + Paraglide: The Working Setup
Botched hreflang costs you twice: confused indexing on Google and confused locale serving on AI engines. The setup below is what I run on every bilingual EN/ES SvelteKit + Paraglide build, including this site. For the wider picture see multilingual SEO on SvelteKit; the service page is multilingual SEO.
I am Ignacio (IGNAX), Spain-born, Paraguay-based, solo full-stack developer. Bilingual EN/ES is my home turf.
What is hreflang, in one paragraph?
Hreflang is an HTML <link> tag (or HTTP header, or sitemap annotation) that tells search engines which language and region a page is intended for, and where to find the sibling pages in other languages. It is what prevents Google from serving your Spanish page to a French user, or your US page to a UK user, when better-matching content exists.

Why does hreflang matter for AEO?
AI engines inherit some of this from search engines, but they also reason about locale independently. A bilingual site without hreflang gives ChatGPT and Perplexity an ambiguous signal, they cannot tell whether /articulo is the Spanish equivalent of /article or an unrelated page. Hreflang plus a shared canonicalSlug makes the relationship explicit. AI engines cite locale-correct URLs more often when the sibling graph is wired.
What does a correct hreflang block look like?
Every page on a bilingual EN/ES site renders three link tags in the <head>:
<link rel="alternate" hreflang="en" href="https://ignax.dev/articles/what-is-aeo" />
<link rel="alternate" hreflang="es" href="https://ignax.dev/articulos/que-es-aeo" />
<link rel="alternate" hreflang="x-default" href="https://ignax.dev/articles/what-is-aeo" />
Three rules that get violated constantly:
- Every locale points to itself. The EN page lists its own URL under
hreflang="en"as well as the ES sibling underhreflang="es". - Sibling URLs must reciprocate. The ES page must also emit the EN entry pointing back. Missing return links is the most common Search Console error.
- x-default is required when you have more than one locale. Without it, Google guesses.
For the broader SEO context see multilingual SEO on SvelteKit. The canonical Google reference is Google Search Central, localized versions.
How does Paraglide JS fit in?
Paraglide JS is the i18n compiler I default to for SvelteKit. It compiles your messages at build time into tree-shakable functions, so unused translations do not ship to the browser. What makes it win in production:
- Type-safe message keys. Misspelled keys fail TypeScript, not silently fall back to English at runtime.
- Tree-shaking. A page that uses 12 messages ships 12 messages, not the entire catalog.
- SSR-friendly. Works cleanly with SvelteKit's reroute hook for locale-prefixed URLs.
Install:
npx @inlang/paraglide-js init
That creates messages/en.json, messages/es.json, and the compiler config. Add Paraglide to your SvelteKit vite.config.ts:
import { paraglideVitePlugin } from '@inlang/paraglide-js';
import { sveltekit } from '@sveltejs/kit/vite';
import { defineConfig } from 'vite';
export default defineConfig({
plugins: [
sveltekit(),
paraglideVitePlugin({
project: './project.inlang',
outdir: './src/lib/paraglide',
}),
],
});
That is the entire Paraglide wiring. The remaining work is locale-prefixed routes and the hreflang emission.
How do I wire locale-prefixed routes in SvelteKit?
Use SvelteKit's reroute hook to map URLs like /es/articulos/que-es-aeo to the underlying SvelteKit route /articles/[slug]:
// src/hooks.ts
import { deLocalizeUrl } from '$lib/paraglide/runtime';
import type { Reroute } from '@sveltejs/kit';
export const reroute: Reroute = ({ url }) => {
return deLocalizeUrl(url).pathname;
};
The Paraglide runtime exposes deLocalizeUrl and localizeHref helpers. The reroute strips the locale prefix so the existing SvelteKit route matches; the locale is captured separately and used to pick the right message catalog.
How do I pair sibling URLs in the content registry?
This is where the design pays off. Each markdown file in src/content/articles/en/ and src/content/articles/es/ shares a canonicalSlug front-matter field. Example:
src/content/articles/en/what-is-aeo.md:
slug: what-is-aeo
canonicalSlug: what-is-aeo
lang: en
src/content/articles/es/que-es-aeo.md:
slug: que-es-aeo
canonicalSlug: what-is-aeo
lang: es
The loader groups files by canonicalSlug. On render. The EN page knows its ES sibling exists and where to find it. The hreflang block iterates the group.
// src/lib/content/loader.ts (excerpt)
export function getSiblings(canonicalSlug: string) {
const all = loadAllContent();
return all.filter((c) => c.canonicalSlug === canonicalSlug);
}
The same pattern works for services, case studies, and any other content type. One source of truth, deterministic sibling resolution.
What does the hreflang emission look like in +layout.svelte?
The link tags belong in <svelte:head> of the layout that wraps content pages:
<script lang="ts">
import { page } from '$app/state';
import { SITE } from '$lib/seo/site';
let { children } = $props();
let siblings = $derived(page.data.siblings ?? []);
let pathByLang = $derived(
Object.fromEntries(siblings.map((s) => [s.lang, s.pathname]))
);
let defaultUrl = $derived(pathByLang.en ?? page.url.pathname);
</script>
<svelte:head>
{#each siblings as s}
<link rel="alternate" hreflang={s.lang} href="{SITE.url}{s.pathname}" />
{/each}
<link rel="alternate" hreflang="x-default" href="{SITE.url}{defaultUrl}" />
</svelte:head>
{@render children()}
The page.data.siblings comes from the page-level +page.server.ts that loads the current content entry and its siblings via canonicalSlug. The link tags emit on every render, in SSR HTML, where Google and AI crawlers see them.
What about the sitemap?
The sitemap should also annotate hreflang per URL. SvelteKit endpoint at src/routes/sitemap.xml/+server.ts:
import { loadAllContent } from '$lib/content/loader';
import { SITE } from '$lib/seo/site';
export const prerender = true;
export async function GET() {
const all = await loadAllContent();
const groups = new Map<string, typeof all>();
for (const c of all) {
const k = `${c.type}:${c.canonicalSlug}`;
if (!groups.has(k)) groups.set(k, []);
groups.get(k)!.push(c);
}
const urls: string[] = [];
for (const group of groups.values()) {
for (const c of group) {
const alts = group
.map(
(s) =>
`<xhtml:link rel="alternate" hreflang="${s.lang}" href="${SITE.url}${urlFor(s)}" />`
)
.join('');
urls.push(`<url><loc>${SITE.url}${urlFor(c)}</loc>${alts}</url>`);
}
}
const xml = `<?xml version="1.0" encoding="UTF-8"?>
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9"
xmlns:xhtml="http://www.w3.org/1999/xhtml">
${urls.join('\n')}
</urlset>`;
return new Response(xml, {
headers: { 'content-type': 'application/xml; charset=utf-8' },
});
}
The sitemap and the <link> tags must agree. The content registry feeds both, so drift is impossible.
How do I validate hreflang in production?
I run four checks on every bilingual launch:
- View source on a representative EN page and the ES sibling. Confirm each page lists itself, the other, and
x-default. - Search Console URL Inspection: paste the EN URL, then the ES URL. Confirm Google sees the alternates.
- International Targeting report in Search Console after 14 days. Look for "No errors" under the Language tab.
- External validator: Aleyda Solís's hreflang testing tool or the Merkle hreflang testing tool. Both surface the most common mistakes.
The most common bug I see on existing sites: EN page lists ES sibling but ES page does not list EN sibling. That breaks the return-link rule and triggers errors in International Targeting.
What about Cloudflare Pages?
Cloudflare Pages does nothing special for hreflang. The link tags emit in the SvelteKit-rendered HTML; Cloudflare's edge network serves that HTML unmodified. The only configuration that matters is the SvelteKit prerender setting, each locale's HTML is cached separately by URL.
For the broader Cloudflare Pages SEO context see Cloudflare Pages SEO setup.
What are the best external references?
- Google Search Central, localized versions: canonical Google hreflang spec.
- SvelteKit i18n documentation: official SvelteKit i18n patterns.
- Paraglide JS documentation: full Paraglide setup and API.
- Aleyda Solís hreflang generator: generator + validator.
The pattern is one shared canonicalSlug per content pair, hreflang emitted from a layout, sitemap annotated by the same loader, validated in Search Console after 14 days. Bilingual SEO is mechanical once the registry is wired. For the broader story see multilingual SEO on SvelteKit.
Ready to ship bilingual SEO on your stack? Email hello@ignax.dev with your URL. I run the audit before our call.
Frequently asked questions
Why use Paraglide JS over @sveltejs/i18n or sveltekit-i18n?
Paraglide compiles your messages at build time into tree-shakable functions, so unused translations do not ship to the browser. It is type-safe, SSR-friendly, and works cleanly with SvelteKit's reroute hook for locale-prefixed URLs. The alternatives are fine for hobby projects; Paraglide is what I default to for production bilingual sites because the bundle savings and type safety pay off as the message catalog grows.
Do I need separate URLs per locale or can I use a query parameter?
Separate URLs. Google strongly prefers locale-prefixed paths (/en/, /es/) or locale subdomains (en.example.com, es.example.com) over query parameters. Subdirectories are easier to ship on Cloudflare Pages and easier to debug. I default to /en/path and /es/ruta with the root redirecting to the user's preferred locale via Accept-Language.
What is x-default and when do I need it?
x-default is the fallback hreflang entry pointing to the URL Google should serve when the user's locale does not match any of your declared locales. Without x-default, Google may serve an unintended locale to international users. I set x-default to the English URL on every page because EN is my broader-audience default. Sites with a global EN landing and locale-specific deep pages may want x-default pointing to the landing.
How do I pair sibling URLs in a content registry?
Each markdown file in /content/articles/en/ and /content/articles/es/ shares a canonicalSlug front-matter field. The loader groups files by canonicalSlug, so on render. The EN page knows its ES sibling and vice versa. The hreflang block iterates the group. The same pattern works for services, case studies, and any other content type.
Does Cloudflare Pages do anything special for hreflang?
No, Cloudflare Pages serves the HTML your SvelteKit build produces. The hreflang emission happens in the +layout.svelte head block at render time. Cloudflare's edge network does not modify the link tags. Make sure your prerender setting is correct so each locale's HTML is cached separately at the edge; that is a SvelteKit configuration issue, not a Cloudflare one.
How do I verify hreflang is working?
Three checks: view source on any page and verify the link tags emit one entry per declared locale plus a self-reference plus x-default. Run the page through the Google Rich Results Test or Search Console URL Inspection. Then check the International Targeting report in Search Console after 14 days for No errors. Errors there are usually missing return links between siblings.