Hreflang i canonical w Next.js — SEO wielojęzycznych stron bez duplikacji

Jak poprawnie ustawić hreflang i canonical w Next.js App Router? Unikanie duplikacji treści, konfiguracja metadata API, wielojęzyczna sitemap i typowe błędy SEO.

Opublikowano

10 kwietnia 2026 14:50

Czytanie

3 min czytania

Aktualizacja

15 kwietnia 2026 11:52

Czym jest hreflang i dlaczego jest krytyczny?

Hreflang to atrybut HTML, który informuje Google, która wersja językowa strony jest przeznaczona dla danego regionu lub języka. Bez hreflang Google może wyświetlić polską wersję użytkownikowi anglojęzycznemu lub potraktować wersje językowe jako duplikaty (kanibalizacja). Może też indeksować tylko jedną wersję. Krótko mówiąc: bez hreflang czeka nas bałagan, podczas gdy hreflang wprowadza kontrolę i porządek.

Canonical (link rel="canonical") wskazuje „źródłową" wersję strony i w ten sposób eliminuje duplikaty wynikające z parametrów URL, trailing slashy, czy wariantów http/https. Oba tagi "współpracują" ze sobą, ponieważ hreflang łączy wersje językowe, canonical wyznacza kanoniczną wersję w każdym języku.

Hreflang w Next.js App Router — Metadata API

Code
// app/[locale]/page.tsx
import type { Metadata } from 'next'
 
const locales = ['pl', 'en', 'de']
const baseUrl = 'https://strivelab.pl'
 
// Polska wersja na rootu domeny, pozostałe języki z prefiksem
const getLocaleUrl = (locale: string, path = '') =>
  locale === 'pl' ? `${baseUrl}${path}` : `${baseUrl}/${locale}${path}`
 
export async function generateMetadata({
  params,
}: {
  params: Promise<{ locale: string }>
}): Promise<Metadata> {
  const { locale } = await params
 
  return {
    alternates: {
      canonical: getLocaleUrl(locale),
      languages: {
        pl: baseUrl,
        en: `${baseUrl}/en`,
        de: `${baseUrl}/de`,
        'x-default': baseUrl,
      },
    },
  }
}

Wynikowy HTML:

Code
<link rel="canonical" href="https://strivelab.pl/" />
<link rel="alternate" hreflang="pl" href="https://strivelab.pl/" />
<link rel="alternate" hreflang="en" href="https://strivelab.pl/en" />
<link rel="alternate" hreflang="de" href="https://strivelab.pl/de" />
<link rel="alternate" hreflang="x-default" href="https://strivelab.pl/" />

Polska wersja działa na rootu domeny (https://strivelab.pl/) bez prefiksu /pl — domena .pl już precyzuje rynek. Pozostałe języki mają własny prefiks (/en, /de). Kluczową zasadą jest by canonical, hreflang, sitemap i linkowanie wewnętrzne były konsekwentnie używane wobec tego samego wariantu URL.

Dynamiczne strony z parametrami

Code
// app/[locale]/blog/[slug]/page.tsx
export async function generateMetadata({
  params,
}: {
  params: Promise<{ locale: string; slug: string }>
}): Promise<Metadata> {
  const { locale, slug } = await params
  const post = await getPost(slug, locale)
 
  return {
    title: post.title,
    description: post.excerpt,
    alternates: {
      canonical: getLocaleUrl(locale, `/blog/${slug}`),
      languages: Object.fromEntries(
        locales.map((l) => [
          l,
          getLocaleUrl(l, `/blog/${post.slugs[l] || slug}`),
        ]),
      ),
    },
  }
}

Jeśli slugi są tłumaczone (/pl/blog/jak-zbudowac-strone vs /en/blog/how-to-build-website), każda wersja językowa musi wskazywać na poprawny slug w danym języku.

Canonical — eliminacja duplikacji

Trailing slash

Code
// next.config.ts
const nextConfig = {
  trailingSlash: false, // Wymuś brak trailing slash
}

Dodatkowo w pliku middleware.ts:

Code
if (pathname.length > 1 && pathname.endsWith('/')) {
  return NextResponse.redirect(new URL(pathname.slice(0, -1), request.url), 308)
}

Parametry URL

Strona /products i /products?sort=price to ta sama treść. Canonical powinien wskazywać wersję bez parametrów:

Code
export const metadata: Metadata = {
  alternates: {
    canonical: '/products', // Bez ?sort=, ?page=, ?filter=
  },
}

www vs non-www

Wybierz jedną wersję i przekieruj drugą. W Vercel — konfiguracja domeny. Na VPS — nginx redirect:

Code
server {
    server_name www.strivelab.pl;
    return 301 https://strivelab.pl$request_uri;
}

Helper do generowania alternates

Code
// lib/seo.ts
const baseUrl = 'https://strivelab.pl'
const locales = ['pl', 'en'] as const
 
const getLocaleUrl = (locale: (typeof locales)[number], path: string) =>
  locale === 'pl' ? `${baseUrl}${path}` : `${baseUrl}/${locale}${path}`
 
export function generateAlternates(
  locale: (typeof locales)[number],
  localizedPaths: Record<(typeof locales)[number], string>,
) {
  const languages = Object.fromEntries(
    locales.map((l) => [l, getLocaleUrl(l, localizedPaths[l])]),
  )
 
  return {
    canonical: getLocaleUrl(locale, localizedPaths[locale]),
    languages: {
      ...languages,
      'x-default': `${baseUrl}${localizedPaths.pl}`,
    },
  }
}
Code
// Użycie
export async function generateMetadata(): Promise<Metadata> {
  return {
    alternates: generateAlternates('pl', {
      pl: '/uslugi',
      en: '/services',
    }),
  }
}

Sitemap wielojęzyczna z alternates

Code
// app/sitemap.ts
import type { MetadataRoute } from 'next'
 
export default function sitemap(): MetadataRoute.Sitemap {
  const baseUrl = 'https://strivelab.pl'
  const locales = ['pl', 'en'] as const
 
  const getLocaleUrl = (locale: string, path: string) =>
    locale === 'pl' ? `${baseUrl}${path}` : `${baseUrl}/${locale}${path}`
 
  const pages = [
    { plPath: '', enPath: '' },
    { plPath: '/uslugi', enPath: '/services' },
    { plPath: '/kontakt', enPath: '/contact' },
    { plPath: '/blog', enPath: '/blog' },
  ]
 
  return pages.flatMap((page) =>
    locales.map((locale) => ({
      url: getLocaleUrl(locale, locale === 'pl' ? page.plPath : page.enPath),
      lastModified: new Date(),
      alternates: {
        languages: Object.fromEntries(
          locales.map((l) => [
            l,
            getLocaleUrl(l, l === 'pl' ? page.plPath : page.enPath),
          ]),
        ),
      },
    })),
  )
}

Typowe błędy hreflang

1. Brakujący x-default

x-default wskazuje wersję dla użytkowników, których język nie jest obsługiwany. Warto zaznaczyć, że nie jest to obowiązkowe w każdym projekcie, ale zwykle warto go dodać, jeśli masz stronę domyślną albo selektor języka.

Jeśli strona PL wskazuje na EN, to EN musi wskazywać z powrotem na PL. W wypadku braku wzajemności = Google ignoruje hreflang.

3. Canonical i hreflang wskazują na różne URL-e

Canonical powinien wskazywać na siebie (w danym języku), nie na inną wersję językową. Każda wersja językowa ma własny canonical.

4. Hreflang na stronach z noindex

Jeśli strona ma posiada atrybut noindex, wtedy hreflang jest ignorowany.

Weryfikacja

  • Google Search Console → inspekcja konkretnych URL-i i raport indeksowania,
  • Ahrefs / Screaming Frog — audyt hreflang na dużą skalę,
  • Ręczne sprawdzenie — View Source → szukaj rel="alternate" hreflang.

Podsumowanie

Hreflang i canonical w Next.js App Router to konfiguracja Metadata API i nie wymaga ręcznego pisania tagów <link>. Kluczowych zasad jest kilka: każda wersja językowa ma własny canonical, linki hreflang muszą być wzajemne, a x-default jest zwykle pomocny, ale nie jest wymogiem dla każdej strony.

Najczęściej zadawane pytania

Czy hreflang wpływa na ranking?

Hreflang nie jest czynnikiem rankingowym i nie ma wpływu na poprawę pozycji, ale poprawia trafność wyświetlanej wersji, co wpływa na CTR i doświadczenie użytkownika. Jeśli miałby jakikolwiek wpływ to pośredni.

Czy muszę mieć osobne domeny dla języków?

Nie, podfoldery (/pl/, /en/) z hreflang to rekomendowane przez Google podejście,a osobne domeny (.pl, .com) to opcja raczej dla dużych marek.

Co jeśli mam tylko jeden język?

Nie potrzebujesz hreflang, jedynie ustaw canonical na każdej stronie — eliminuje duplikaty z parametrów URL i trailing slashy.

Pracuję z tym zawodowo.

Jeśli chcesz połączyć SEO, analitykę, Google Ads i warstwę techniczną strony w jeden sensowny system wzrostu, skontaktuj się ze mną. Pomagam układać wdrożenia, które nie kończą się na samym tagowaniu, ale wspierają widoczność, pomiar i konwersję.

O autorze

Maciej Sala

Maciej Sala — project manager i frontendowiec z doświadczeniem w marketingu internetowym. Na co dzień pracuję z Reactem, Next.js i TypeScriptem, łącząc perspektywę produktową z praktycznym podejściem do kodu. Przez kilka lat związany z branżą gier wideo jako project manager i game designer.

Absolwent historii na Uniwersytecie Jagiellońskim i studiów podyplomowych z marketingu internetowego na Akademii Górniczo-Hutniczej w Krakowie. Poza pracą trenuje na siłowni, maluje figurki i realizuje własne projekty.

Biblioteka wiedzy

Czytaj dalej

Zobacz więcej wpisów
Anthropic uderza w Figmę i Adobe — oto Claude Design

Anthropic uderza w Figmę i Adobe — oto Claude Design

Anthropic wypuścił właśnie narzędzie AI do tworzenia stron, landing page'ów i prezentacji z promptu. Oto co wiemy o Claude Design i Opus 4.7 — i co to oznacza dla developerów.

Maciej Sala

Maciej Sala

Founder Strivelab

Astro.js vs Next.js — które narzędzie wybrać w 2026 roku?

Astro.js vs Next.js — które narzędzie wybrać w 2026 roku?

Fachowe porównanie Astro.js i Next.js z perspektywy developera pracującego na co dzień w Next.js. Architektura, wydajność, SEO, DX, koszty i konkretne use case — z benchmarkami i przykładami kodu.

Maciej Sala

Maciej Sala

Founder Strivelab