Przejdź do treści

Wielojęzyczność i hreflang w Astro SSG: Zasięg globalny, wdrożenie lokalne

Wielojęzyczna strona w Astro SSG bez chaosu w hreflang — jak poprawnie skonfigurować i18n dla sklepów i serwisów wchodzących na rynki zagraniczne.

Maciej Sala

Founder StriveLab

7 min czytaniaOpublikowano 28 maja 2026 (Aktualizacja 24 czerwca 2026)

Gdzie SSG w Astro pasuje do wielojęzycznego sklepu

dobrze pasuje do wielojęzycznej warstwy treści sklepu z kilku konkretnych powodów:

  • Każdy język dostaje własny, z góry wygenerowany HTML — optymalny dla i indeksacji, bez tłumaczenia po stronie klienta, które wyszukiwarki widzą gorzej.
  • Łatwość buforowania na CDN — statyczne pliki rozkładają się globalnie, więc użytkownik w Tokio i w Berlinie dostaje stronę z bliskiego węzła.
  • Brak JavaScriptu po stronie klienta bez wyraźnej potrzeby — Astro pozwala dołączać interaktywność tylko do wybranych komponentów.
  • Kolekcje treści Astro (Content Collections) — typowane, walidowane zarządzanie treścią w wielu językach.

Podejście statyczne jest łatwiejsze do buforowania i bardziej przewidywalne dla robotów niż tłumaczenie treści dopiero po stronie klienta. Nie oznacza to jednak, że cały sklep musi być statyczny — aktualne ceny, stany magazynowe, konto i koszyk mogą korzystać z API, wysp interaktywnych albo tras renderowanych na żądanie.

Konfiguracja i18n w Astro: trasy językowe i prefiksy

Astro ma już wbudowaną obsługę tras i18n, a przykładowa konfiguracja deklaruje obsługiwane języki oraz język domyślny:

Code
// astro.config.mjs
import { defineConfig } from 'astro/config'
 
export default defineConfig({
  site: 'https://twojsklep.com',
  i18n: {
    locales: ['pl', 'en', 'de', 'ja'],
    defaultLocale: 'pl',
    routing: {
      // false = adresy domyślnego języka są krótsze (bez prefiksu /pl/)
      prefixDefaultLocale: false,
    },
  },
})

Decyzja prefixDefaultLocale jest istotna o tyle, że false daje czystsze adresy dla języka domyślnego (/produkt zamiast /pl/produkt), co w większości przypadków wystarcza. true wybieraj wtedy, jeśli szczególnie zależy Ci na spójności struktury (każdy język z prefiksem).

Konfiguracja musi odpowiadać strukturze src/pages. Przy prefixDefaultLocale: false strony języka domyślnego znajdują się bezpośrednio w katalogu pages, a pozostałe w katalogach językowych:

Code
src/pages/
├── index.astro
├── produkty/[slug].astro
├── en/
│   ├── index.astro
│   └── products/[slug].astro
└── de/
    ├── index.astro
    └── produkte/[slug].astro

Trasy oparte na prefiksie (/en/, /de/) są proste i działają ze statycznym hostingiem. Subdomeny (en.sklep.com) pozwalają mocniej rozdzielić serwisy, ale to rozwiązanie komplikuje wiele spraw i zwykle dla SSG prefiks jest najprostszym, najwygodniejszym wyborem.

Astro pozwala również zdefiniować języki zapasowe, które poprzez domyślny wariant redirect przenosi użytkownika na istniejący URL w innym języku. Wariant rewrite pozostawia żądany lokalizowany URL, ale wyświetla pod nim treść z języka zapasowego. Dla SEO bezpieczniejszy jest zwykle redirect albo 404, ponieważ niebezpieczeństwo tkwi w tym, że rewrite może utworzyć wiele adresów z tą samą treścią, których nie należy przedstawiać jako prawdziwych tłumaczeń w hreflang.

Organizacja treści wielojęzycznej w Kolekcjach treści Astro

Dla sklepu internetowego sprawdza się trzymanie treści w folderach dla każdego języka wewnątrz Kolekcji treści Astro (Content Collections):

Code
src/content/
├── produkty/
│   ├── pl/      # opisy po polsku
│   ├── en/      # opisy po angielsku
│   ├── de/      # opisy po niemiecku
│   └── ja/      # opisy po japońsku

Każdy wpis powinien mieć stabilny klucz łączący tłumaczenia niezależnie od języka i sluga:

Code
---
translationKey: kubek-ceramiczny-300
locale: pl
slug: kubek-ceramiczny
title: Kubek ceramiczny
---

Wersja angielska może używać tego samego translationKey, ale sluga ceramic-mug. Dzięki temu generator nie zgaduje odpowiedników na podstawie nazw plików. Słowniki interfejsu (etykiety przycisków, komunikaty) trzymaj osobno, np. w src/i18n/*.json, i sięgaj po nie funkcją pomocniczą t('button.dodaj_do_koszyka') w oparciu o Astro.currentLocale.

Ustaw także język dokumentu w głównym layoucie, bo to wyraźnie pomaga przeglądarkom i technologiom asystującym, choć Google ustala język przede wszystkim na podstawie widocznej treści:

Code
<html lang={Astro.currentLocale ?? 'pl'}>

Hreflang w Astro — najważniejszy sygnał międzynarodowego SEO

mówi Google, które URL-e są językowymi lub regionalnymi odpowiednikami. Oczywiście, nie zastępuje tłumaczenia, linku kanonicznego ani rozpoznawania języka na podstawie treści, ale pomaga wybrać właściwy adres dla użytkownika na danym rynku.

Tagi hreflang możesz generować w <head> szablonu. Astro daje do tego funkcję pomocniczą getAbsoluteLocaleUrl. W przykładzie poniżej zakładamy pełne pokrycie tłumaczeń, taką samą końcówkę adresu w każdym języku i zmianę wyłącznie prefiksu:

Code
---
// src/layouts/BaseLayout.astro
import { getAbsoluteLocaleUrl } from 'astro:i18n';
 
const locales = ['pl', 'en', 'de', 'ja'] as const;
const defaultLocale = 'pl';
const currentLocale = Astro.currentLocale ?? defaultLocale;
 
const localePrefixPattern = new RegExp(`^/(${locales.filter((locale) => locale !== defaultLocale).join('|')})(?=/|$)`);
const routePath = Astro.url.pathname
  .replace(localePrefixPattern, '')
  .replace(/^\/|\/$/g, '');
 
const canonical = getAbsoluteLocaleUrl(currentLocale, routePath);
---
<head>
  <!-- Wersje językowe tej samej strony -->
  {locales.map((locale) => (
    <link
      rel="alternate"
      hreflang={locale}
      href={getAbsoluteLocaleUrl(locale, routePath)}
    />
  ))}
 
  <!-- Wersja domyślna dla nieobsłużonych języków -->
  <link
    rel="alternate"
    hreflang="x-default"
    href={getAbsoluteLocaleUrl(defaultLocale, routePath)}
  />
 
  <!-- Kanoniczny URL bieżącej wersji -->
  <link rel="canonical" href={canonical} />
</head>

Jeśli tłumaczysz końcówki adresów (/produkt/, /en/product/, /de/produkt-de/), to nie podmieniaj prefiksu mechanicznie. Wtedy potrzebujesz mapy odpowiedników dla każdego języka i generujesz hreflang z tej mapy, tylko dla realnie istniejących wersji. Trzeba zrobić to możliwie jak najdokładniej — im dłużej istniały stare adresy, tym boleśniej odczuje się ich zmianę. Tracimy wtedy realną moc starych adresów.

Trzy reguły, których pilnuj rygorystycznie:

  • Kody lokalizacji poprawne. Używaj en, de, zh-CN — a nie wymyślonych wariantów, bo niespójne kody psują cały mechanizm.

  • x-default jako świadoma wersja zapasowa. Wskazuje stronę dla użytkowników, których języka nie obsługujesz i zwykle będzie to język domyślny albo selektor języka.

  • Link kanoniczny dla każdej wersji językowej. Każda wersja wskazuje na siebie jako kanoniczną, a nie na język domyślny.

Częściowe tłumaczenia — pułapka hreflang w wielojęzycznym sklepie

Teraz uwaga na przypadek, który wywraca standardowe konfiguracje. Dopóki każda strona istnieje w każdym języku i struktura adresów jest jednolita, opcja i18n w @astrojs/sitemap oraz szablon emitujący hreflang powinny wystarczyć.

Problem pojawia się, gdy masz częściowe tłumaczenia, różne slugi albo treść zapasową. Globalna lista języków nie wystarcza wtedy do ustalenia, które warianty konkretnego produktu naprawdę istnieją. Mechaniczne wygenerowanie pełnego zestawu alternatyw może wskazać nieistniejące strony albo adresy wyświetlające treść zapasową.

Standardowy hreflang wystarczy, dopóki każda strona istnieje w każdym języku.

Rozwiązaniem jest jedno źródło relacji między tłumaczeniami, wykorzystywane zarówno przez layout, jak i sitemapę. Poniższa funkcja grupuje wpisy po translationKey i zachowuje wyłącznie faktycznie istniejące wersje:

Code
// src/lib/product-translations.ts
import { getCollection, type CollectionEntry } from 'astro:content'
 
type Product = CollectionEntry<'produkty'>
 
export type ProductAlternate = {
  locale: string
  path: string
}
 
export async function getProductTranslationGroups() {
  const products = await getCollection('produkty')
 
  return products.reduce((groups, product: Product) => {
    const key = product.data.translationKey
    const group = groups.get(key) ?? []
    group.push(product)
    groups.set(key, group)
    return groups
  }, new Map<string, Product[]>())
}
 
export function toAlternates(products: Product[]): ProductAlternate[] {
  return products.map((product) => ({
    locale: product.data.locale,
    // Tu celowo pokazany wariant z tłumaczoną końcówką i własnym slugiem
    // per język. Jeśli trzymasz niezmienną końcówkę (/en/produkty/...),
    // zostaw stały segment i podmieniaj wyłącznie prefiks języka.
    path:
      product.data.locale === 'pl'
        ? `/produkty/${product.data.slug}/`
        : `/${product.data.locale}/produkty/${product.data.slug}/`,
  }))
}

Ten sam translationKey przy różnych slugach (kubek-ceramiczny, ceramic-mug) pozwala budować poprawne odpowiedniki nawet wtedy, gdy końcówki adresów się różnią. Ważniejszy od konkretnej metody grupowania jest stabilny translationKey i jedna funkcja budująca docelowe ścieżki.

Layout nie powinien już przechodzić po globalnej tablicy języków. Otrzymuje alternatywy bieżącego produktu i emituje wyłącznie te adresy:

Code
---
// src/layouts/ProductLayout.astro
const { alternates, currentLocale } = Astro.props
const site = new URL(Astro.site ?? Astro.url.origin)
const current = alternates.find((item) => item.locale === currentLocale)
const defaultAlternate = alternates.find((item) => item.locale === 'pl')
---
 
{alternates.map((item) => (
  <link
    rel="alternate"
    hreflang={item.locale}
    href={new URL(item.path, site)}
  />
))}
{defaultAlternate && (
  <link
    rel="alternate"
    hreflang="x-default"
    href={new URL(defaultAlternate.path, site)}
  />
)}
{current && <link rel="canonical" href={new URL(current.path, site)} />}

Każda wersja musi otrzymać ten sam zestaw wzajemnych odnośników, łącznie z odnośnikiem do samej siebie. Produkt dostępny wyłącznie po polsku dostanie link kanoniczny do samej siebie, ale nie potrzebuje fikcyjnych alternatyw en i de.

Tłumaczyć końcówki adresów URL czy zostawić niezmienne?

Teraz wchodzimy w decyzję architektoniczną, która ma konsekwencje zarówno operacyjne, jak i SEO. Jeśli prefiks języka się zmienia, a końcówka adresu nie (/en/produkt, /de/produkt), generowanie hreflang sprowadza się do podmiany fragmentu adresu, przełącznik języka nie potrzebuje mapy odpowiedników, a adresy URL pozostają „kopiowalne”. Dla anglojęzycznego sklepu z tłumaczonym pokryciem to zwykle właściwy wybór.

Warto zaznaczyć natomiast, że dla treści mocno osadzonej w danym języku (np. blog kulinarny celujący w lokalną publiczność) tłumaczone końcówki adresów warte kosztu operacyjnego, ponieważ same w sobie niosą wartość SEO. To decyzja dla konkretnego projektu i musisz wyważyć koszt utrzymania wobec zysku z lokalnych fraz w adresie.

Sitemapa z hreflang i dane strukturalne w Astro

Przy pełnym pokryciu i jednakowych slugach możesz użyć opcji i18n w @astrojs/sitemap. Przy modelu z translationKey wygeneruj własny statyczny endpoint, korzystający z tych samych grup co layout:

Code
// src/pages/sitemap.xml.ts
import type { APIRoute } from 'astro'
import {
  getProductTranslationGroups,
  toAlternates,
} from '../lib/product-translations'
 
const escapeXml = (value: string) =>
  value.replace(
    /[<>&'"]/g,
    (character) =>
      ({
        '<': '&lt;',
        '>': '&gt;',
        '&': '&amp;',
        "'": '&apos;',
        '"': '&quot;',
      })[character]!,
  )
 
export const GET: APIRoute = async ({ site }) => {
  if (!site) throw new Error('Ustaw `site` w astro.config.mjs')
 
  const groups = await getProductTranslationGroups()
  const entries = [...groups.values()].flatMap((products) => {
    const alternates = toAlternates(products)
    const defaultAlternate = alternates.find((item) => item.locale === 'pl')
    const links = [
      ...alternates,
      ...(defaultAlternate
        ? [{ locale: 'x-default', path: defaultAlternate.path }]
        : []),
    ]
 
    return alternates.map(
      (current) => `
      <url>
        <loc>${escapeXml(new URL(current.path, site).href)}</loc>
        ${links
          .map(
            (item) => `
          <xhtml:link rel="alternate" hreflang="${item.locale}" href="${escapeXml(new URL(item.path, site).href)}" />
        `,
          )
          .join('')}
      </url>
    `,
    )
  })
 
  return new Response(
    `<?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">
      ${entries.join('')}
    </urlset>`,
    { headers: { 'Content-Type': 'application/xml' } },
  )
}

Google traktuje adnotacje w HTML, nagłówkach HTTP i sitemapie jako równoważne metody — nie ma dodatkowych punktów SEO za utrzymywanie wszystkich naraz. Jeśli jednak generujesz HTML i sitemapę, oba miejsca muszą korzystać z tego samego źródła danych, aby nie rozjechały się po kolejnej publikacji.

Drugim kluczowym elementem są dane strukturalne (schema.org), które pomagają wyszukiwarkom lepiej zrozumieć kontekst treści. Należy wstawić właściwość inLanguage zgodną z Astro.currentLocale — precyzyjnie opisuje ona język danej encji, ale nie zastępuje tagów hreflang, które służą do wskazywania alternatywnych wersji językowych strony.

Code
---
const productSchema = {
  '@context': 'https://schema.org',
  '@type': 'Product',
  inLanguage: Astro.currentLocale ?? 'pl',
  // pozostałe pola produktu
};
---
<script type="application/ld+json" set:html={JSON.stringify(productSchema)} />

Test hreflang po buildzie: nie ufaj samej konfiguracji

Poprawność oceniaj na wygenerowanych plikach i nie sugeruj się samym astro.config.mjs. Dla każdego publicznego URL-a, test regresji powinien sprawdzić:

  • odpowiedź 200 oraz właściwe <html lang>;
  • link kanoniczny do samej siebie zgodny z bieżącym adresem;
  • self-reference i wzajemność wszystkich wpisów hreflang;
  • brak alternatyw prowadzących do 404, redirectu albo treści zapasowej;
  • zgodność zestawu alternatyw w HTML i sitemapie, jeśli utrzymujesz obie metody.

Przy wdrożeniu na CDN sprawdź również, czy hosting zachowuje trailing slash zgodny z wygenerowanymi linkami kanonicznymi i nie dokłada własnych przekierowań między wariantami adresu.

Kampanie, landing page, tracking konwersji, GA4 i GTM w jednym procesie.
Google Ads i Analityka

Często zadawane pytania

Czy potrzebuję osobnych subdomen dla każdego języka?

Nie. Trasy oparte na prefiksie języka (/en/, /de/) są prostsze, ponieważ działają ze statycznym hostingiem i są pragmatycznym standardem dla SSG. Google nie wskazuje subdomen jako rozwiązania z natury lepszego dla SEO. Subdomeny ułatwiają rozdzielenie serwisów, ale wymagają osobnej konfiguracji DNS, hostingu i monitoringu. Dla większości wielojęzycznych sklepów prefiks jest właściwym wyborem operacyjnym.

Do czego służy tag x-default i czy muszę go dodawać?

x-default wskazuje wersję strony dla użytkowników, których języka nie obsługujesz — to wersja zapasowa dla całej reszty świata. Nie jest bezwzględnym wymogiem dla każdej strony, ale zwykle warto go dodać, jeśli masz wersję domyślną albo selektor języka. Jego brak utrudnia Google wybór adresu dla użytkownika bez dopasowanego języka.

Dlaczego standardowa sitemapa Astro może nie wystarczyć?

Opcja i18n w @astrojs/sitemap łączy adresy na podstawie ich struktury i listy języków. Nie analizuje jednak relacji między wpisami w Kolekcjach treści. Przy częściowych tłumaczeniach lub różnych slugach potrzebujesz własnego generatora, który grupuje treści po stabilnym kluczu tłumaczenia i emituje wyłącznie istniejące wersje.

Tłumaczyć końcówki adresów czy zostawić je niezmienne między językami?

Jeśli zmienia się tylko prefiks języka, a końcówka adresu zostaje taka sama (/en/produkt, /de/produkt), generowanie hreflang sprowadza się do podmiany fragmentu adresu, a przełącznik języka nie potrzebuje mapy odpowiedników. Tłumaczone końcówki adresów mają sens głównie dla treści mocno osadzonych w lokalnym języku, takich jak np. blog celujący w lokalne frazy.

Czy link kanoniczny ma wskazywać na język domyślny, czy na bieżącą wersję?

Na bieżącą wersję, ponieważ każda wersja językowa powinna wskazywać samą siebie jako kanoniczną. Przykładowo, polska strona na polską, a niemiecka na niemiecką i tak dalej.

O autorze

Maciej Sala

Maciej Sala — Product Manager i Frontend Developer z bogatym doświadczeniem w marketingu internetowym oraz SEO. Na co dzień pracuje z Reactem, Next.js i TypeScriptem, a ostatnio także z Astro i narzędziami do automatyzacji procesów AI. Sprawnie łączy perspektywę produktową z praktycznym podejściem do kodu. Przez kilka lat był związany z branżą gier wideo jako project manager i game designer. Absolwent historii na Uniwersytecie Jagiellońskim oraz studiów podyplomowych z marketingu internetowego na AGH w Krakowie. Po godzinach trenuje na siłowni, maluje figurki i rozwijam własne projekty.

Pomagam przekładać takie tematy na konkretne wdrożenia w frontendzie, SEO, analityce i procesie produktowym.

Skontaktuj się ze mną

Biblioteka wiedzy na temat Astro

Czytaj dalej

Zobacz więcej wpisów
Hreflang i link kanoniczny w Next.js — SEO wielojęzycznych stron bez duplikacji

Hreflang i link kanoniczny rozwiązują dwa różne problemy wielojęzycznego SEO: właściwy język wyniku i właściwy adres do indeksowania. W Next.js App Router oba mechanizmy da się generować spójnie przez Metadata API , bez ręcznego utrzymywania tagów w każdej podstronie.

Maciej Sala

Maciej Sala

Founder StriveLab

Wielojęzyczna strona w Next.js App Router (i18n)

W Pages Router Next.js miał wbudowane wsparcie dla i18n i wystarczyło dodać konfigurację i18n w next.config.js , by framework zarządzał routingiem językowym automatycznie. App Router nie ma tego mechanizmu i właśnie dlatego i18n trzeba zaimplementować samodzielnie lub użyć do tego celu biblioteki. Artykuł jest o zbudowaniu wielojęzycznej strony w Next.js.

Maciej Sala

Maciej Sala

Founder StriveLab

QA w technicznym SEO: link kanoniczny, hreflang, metadata i redirecty bez regresji

Najbardziej kosztowne błędy SEO uderzają cicho i niepostrzeżenie. Strona bezproblemowo się ładuje, formularz dalej działa, a użytkownik normalnie z niej korzysta. Jednocześnie Google zaczyna dostawać nieprawidłowy link kanoniczny, brakujący hreflang albo nadany przypadkowo noindex . QA w SEO polega na wyraźnych regułach w kodzie, testach i walidacji danych.

Maciej Sala

Maciej Sala

Founder StriveLab