Multilingual SEO on SvelteKit illustration showing globe with EN and ES URL paths converging on framework emblem

Best Multilingual SEO Setup on SvelteKit (Bilingual EN/ES)

Bilingual SvelteKit SEO done right means more than just hreflang: URL structure, content registry, schema, sitemap, and AEO all have to line up. The setup below is what runs on ignax.dev today. For the hreflang detail in isolation see hreflang in SvelteKit + Paraglide.

I am Ignacio (IGNAX), Spain-born, Paraguay-based, solo full-stack developer. Bilingual EN/ES is the default mode of this site and most of my client work. Full service page at multilingual SEO.

What is the short checklist?

  1. Pick subdirectories (/en/, /es/) over subdomains or country TLDs.
  2. Translate the URL paths, not just the body content (/articles//articulos/).
  3. One shared canonicalSlug per content pair across locales.
  4. Paraglide JS for compiled, type-safe messages.
  5. Hreflang siblings in <head> and in sitemap.xml.
  6. Locale-aware JSON-LD: Person, Organization, Article, Service all emit inLanguage.
  7. Locale-specific Quick Answers and FAQ blocks in front-matter: not machine translations.
  8. Locale-correct llms.txt: one EN, one ES, or one combined with sections per locale.
  9. GSC + Bing Webmaster verified with International Targeting set.
  10. Cloudflare Pages prerender per locale so each HTML is cached separately at the edge.

The rest of this article is rationale and code. Locale-prefixed URL routing diagram for bilingual SvelteKit SEO showing /en and /es path structure

Why subdirectories beat the alternatives?

Multilingual sites can use one of three URL structures.

Structure Example Authority Setup cost When to use
Subdirectories /en/, /es/ Consolidated Low Default, works for 95% of cases
Subdomains en.example.com, es.example.com Split per subdomain Medium Distinct brands per locale
Country TLDs example.com, example.es Separate domains High Local entity per country, big budget

Subdirectories win because they consolidate domain authority across locales (a backlink to your EN page also lifts the ES page indirectly through the canonical structure) and they ship cheaply on Cloudflare Pages with no extra DNS work.

The exception: brands with distinct localized identities (different brand name, different legal entity) that genuinely operate as separate businesses per market. They want subdomains or TLDs. Most do not.

How should I structure URLs across locales?

Translated paths win over locale-prefixed shared paths. Compare:

  • /articles/what-is-aeo (EN) and /articulos/que-es-aeo (ES): translated paths.
  • /en/articles/what-is-aeo (EN) and /es/articles/what-is-aeo (ES): locale-prefixed shared paths.

The first reads as native to humans and to AI engines. Spanish-speaking users trust /articulos/ more than /articles/. AI engines cite locale-correct URLs more often.

Paraglide handles the path translation via the pathnames config:

// project.inlang/settings.json
{
  "locales": ["en", "es"],
  "baseLocale": "en",
  "pathnames": {
    "/articles/[slug]": {
      "en": "/articles/[slug]",
      "es": "/articulos/[slug]"
    },
    "/services/[slug]": {
      "en": "/services/[slug]",
      "es": "/servicios/[slug]"
    }
  }
}

The slug itself is also translated, /articles/what-is-aeo becomes /articulos/que-es-aeo: via the content registry, not Paraglide. Each markdown file declares its own locale-specific slug; the loader pairs them via the shared canonicalSlug.

How does the content registry work?

The registry lives in src/content/. Layout:

src/content/
├── articles/
│   ├── en/
│   │   ├── what-is-aeo.md           # slug: what-is-aeo, canonicalSlug: what-is-aeo
│   │   └── llms-txt-explained.md    # slug: llms-txt-explained, canonicalSlug: llms-txt-explained
│   └── es/
│       ├── que-es-aeo.md            # slug: que-es-aeo, canonicalSlug: what-is-aeo
│       └── llms-txt-explicado.md    # slug: llms-txt-explicado, canonicalSlug: llms-txt-explained
├── services/
│   ├── en/
│   └── es/
└── case-studies/
    ├── en/
    └── es/

Each file has a front-matter canonicalSlug that pairs it with its sibling in the other locale. The loader builds a Map<canonicalSlug, entries[]> once at build time, so any page can resolve its siblings in O(1).

For the schema reference see the Zod front-matter schema in markdown.ts; that is the validation gate. Drift fails the build.

What does locale-aware JSON-LD look like?

Every JSON-LD block emits inLanguage:

<script type="application/ld+json">
{
  "@context": "https://schema.org",
  "@type": "Article",
  "headline": "What is AEO?",
  "inLanguage": "en",
  "url": "https://ignax.dev/articles/what-is-aeo",
  "author": {
    "@type": "Person",
    "name": "Ignacio (IGNAX)",
    "sameAs": ["https://www.linkedin.com/in/ignaxdev", "https://github.com/ignax"]
  },
  "publisher": {
    "@type": "Organization",
    "name": "Ignax",
    "url": "https://ignax.dev"
  }
}
</script>

The Spanish sibling emits "inLanguage": "es". The URL points to the Spanish slug, and the author entity is the same Person (one Ignacio, many languages). The sameAs array stays identical across locales, your LinkedIn profile is not localized.

For the broader AEO schema discussion see what is AEO and the canonical Schema.org documentation.

How do I localize the sitemap?

The sitemap annotates hreflang per URL group. Each canonicalSlug group emits one <url> block per locale, with cross-references to all siblings:

<url>
  <loc>https://ignax.dev/articles/what-is-aeo</loc>
  <xhtml:link rel="alternate" hreflang="en" href="https://ignax.dev/articles/what-is-aeo" />
  <xhtml:link rel="alternate" hreflang="es" href="https://ignax.dev/articulos/que-es-aeo" />
  <xhtml:link rel="alternate" hreflang="x-default" href="https://ignax.dev/articles/what-is-aeo" />
</url>
<url>
  <loc>https://ignax.dev/articulos/que-es-aeo</loc>
  <xhtml:link rel="alternate" hreflang="en" href="https://ignax.dev/articles/what-is-aeo" />
  <xhtml:link rel="alternate" hreflang="es" href="https://ignax.dev/articulos/que-es-aeo" />
  <xhtml:link rel="alternate" hreflang="x-default" href="https://ignax.dev/articles/what-is-aeo" />
</url>

Both URLs reciprocate. The xhtml: namespace is declared at the top of the <urlset>. For the hreflang detail in the page head see hreflang in SvelteKit + Paraglide.

How does AEO change with multiple locales?

AI engines reason about locale per query. A user asking ChatGPT in Spanish gets cited Spanish-language URLs preferentially. That has direct implications for AEO content.

  • Quick Answers must be locale-specific, not machine translations. A Quick Answer translated from English to Spanish by GPT-4 reads stilted; a Quick Answer written natively in es-PY reads correct. Native writing wins citation share.
  • FAQ blocks must be locale-specific. The questions a Spanish-speaking user asks are not always the literal translations of the English questions; they reflect different concerns and idioms.
  • Schema inLanguage matters. AI engines use it to filter citation candidates by locale.
  • llms.txt should be locale-aware. Either one file per locale (/en/llms.txt, /es/llms.txt) or a single file with sections per locale and explicit hreflang annotations on each entry.

For the AEO playbook in general see how to get cited by ChatGPT.

What about es-PY vs es-ES register?

The choice matters. Native Spanish speakers detect register mismatches instantly:

  • es-PY (Paraguay register): voseo OK in conversational copy, formal usted in service pages. No "vosotros", no "vale", no "guay".
  • es-ES (Spain register): "vosotros" appropriate, "vale" and "guay" natural. No "vos", no "che".

For ignax.dev I default to es-PY because most Spanish-speaking clients are LATAM-based. A Spain-targeted site would write es-ES. The wrong register reads as a translation rather than as native content, and it costs trust.

The site can serve both via locale negotiation if the audience genuinely spans both regions, es-419 is the BCP-47 code for Latin American Spanish, es-ES for Spain Spanish. Most bilingual sites pick one and stay consistent.

How do I verify the setup is working?

  • View source on every page: link tags, schema, locale matches the rendered content.
  • Google Search Console URL Inspection for both locale siblings: confirm both are indexed and the alternate is recognized.
  • GSC International Targeting report after 14 days: look for "No errors".
  • Bing Webmaster Tools: confirm both locales appear in the URL inspection.
  • Manual AI citation probe: query 10 EN and 10 ES prompts across ChatGPT, Perplexity, Gemini, Claude; log which locale gets cited per query.
  • Lighthouse with --locale=es: verify the ES page Core Web Vitals match the EN page.

For ongoing maintenance see the weekly loop documented in the growth engine case study.

What external references should I read?

That is the entire bilingual SvelteKit setup. The implementation work is one weekend if you start fresh, one to two weeks if you are retrofitting an existing site. For the implementation detail on hreflang specifically see hreflang in SvelteKit + Paraglide.

Ready to ship bilingual SEO? Email hello@ignax.dev with your URL. I crawl the site before our call so we can talk specifics.

Frequently asked questions

Should I use subdirectories, subdomains, or country TLDs for multilingual SEO?

Subdirectories (/en/, /es/) for almost every case. They consolidate domain authority across locales, are cheap to ship on Cloudflare Pages, and play well with SvelteKit's reroute hook. Subdomains split authority and require separate Search Console properties. Country TLDs (.es, .com.py) are appropriate only when targeting specific markets with locally registered businesses; they are expensive and harder to maintain.

How do I handle locales beyond EN and ES?

The same pattern scales linearly. Add a new directory under src/content/articles/fr/, ship the French message catalog in Paraglide, register the locale in the runtime, and the hreflang emission picks up the new sibling automatically. The content registry pattern means each new locale is N more markdown files plus one Paraglide config entry; no new code.

What is the right URL pattern: translated paths or shared paths?

Translated paths. /articulos/que-es-aeo reads as Spanish to humans and to AI engines; /es/articles/what-is-aeo reads as a translation of an English route. Spanish-speaking users trust translated URLs more, click more, and AI engines cite locale-correct URLs more often. The Paraglide pathnames config maps the English slug to the Spanish slug per route.

How do I avoid duplicate-content penalties between locales?

You do not get penalized for legitimate translations. Google's duplicate-content guidance is specifically about copies of the same content in the same language. Translated content is separate content. Make sure hreflang siblings are wired so Google understands the relationship; that signals legitimate localization rather than duplicate intent.

Should my Spanish content be Latin American or Castilian Spanish?

Depends on the audience. For ignax.dev I default to es-PY (Paraguay register) because most of my Spanish-speaking clients are LATAM-based. For a brand targeting Spain I would write es-ES. The es-PY versus es-ES choice matters for trust, Spaniards detect LATAM idioms instantly, and LATAM clients detect Castilian idioms instantly. Pick one and stay consistent.

What is the AEO angle for multilingual sites?

AI engines cite locale-correct URLs more often when the sibling graph is wired. A Spanish-speaking user asking ChatGPT in Spanish gets cited the Spanish URL, not the English one, but only if hreflang and locale-aware schema are correct. Locale-specific Quick Answers and FAQ blocks per language move the needle here. Translation alone is not enough; the schema and the structured data have to be locale-aware too.