Kamil Owczarek
Opublikowano

Jak nieopublikowane języki po cichu drenowały nasz budżet na Vercelu i zabiły wydajność na mobile

Autorzy

Prowadzimy wielojęzyczny sklep zbudowany na Nuxcie, obsługujący 11 języków. Kiedy doszedł arabski i portugalski — czyli w sumie 13 — nie byliśmy jeszcze gotowi, żeby wypuścić je na produkcję. Zrobiliśmy więc rzecz oczywistą: zablokowaliśmy /ar/ i /pt/ w robots.txt i ukryliśmy je w przełączniku języków.

Problem rozwiązany, prawda?

Tydzień później zużycie na Vercelu poszybowało w górę. Wydajność mobilna w Google Search Console spadła z zielonego na żółty. Coś tu nie grało.

Śledztwo

Po dokładnym audycie sitemap, tagów hreflang, ładowania tłumaczeń i analizy bundle'a znaleźliśmy pięć osobnych problemów — wszystkie wyrastające z jednej przyczyny źródłowej: ograniczenie języków było wdrożone tylko w dwóch miejscach, a reszta kodu radośnie generowała treści dla wszystkich 13 języków.

Oto co działo się pod maską.

Problem 1: sitemapy reklamowały zablokowane strony

Nasz serwis generuje sitemapy dynamicznie — produkty, kolekcje, kategorie, aktualności, inspiracje i projektantów. Każdy handler sitemapy pobierał listę języków z runtime configu i18n:

const languages = (useRuntimeConfig().public.i18n.locales ?? [])
  .map((option: any) => option.code) as Language[]

To zwracało wszystkie 13 języków. Każdy URL produktu był generowany 13 razy. Przy samych ~2000 produktów oznaczało to ~4000 zmarnowanych wpisów w sitemapie wyłącznie dla AR i PT — a w sumie, przez 6 typów treści, jakieś ~5000 zbędnych URL-i.

Crawlery wyszukiwarek odkrywały te adresy przez sitemapę, próbowały je odwiedzić, uderzały w regułę disallow z robots.txt i szły dalej. Szkoda była już jednak zrobiona — każda taka próba zjadała jedno wywołanie funkcji serverless na Vercelu, a przepalony crawl budget oznaczał, że prawdziwe strony były odwiedzane rzadziej.

Problem 2: tagi hreflang wysyłały sprzeczne sygnały

Każda strona serwisu zawierała tagi <link rel="alternate" hreflang="..."> dla wszystkich 13 języków:

<link rel="alternate" hreflang="pl" href="https://example.com/produkt/xyz" />
<link rel="alternate" hreflang="en" href="https://example.com/en/product/xyz" />
<!-- ... 9 more productive languages ... -->
<link rel="alternate" hreflang="ar" href="https://example.com/ar/منتج/xyz" />
<link rel="alternate" hreflang="pt" href="https://example.com/pt/produto/xyz" />

Google widziało dwa sprzeczne sygnały:

  1. Hreflang: „ta strona istnieje po arabsku pod tym adresem”
  2. Robots.txt: „nie wchodź na nic pod /ar/

To dokładnie ten rodzaj sprzecznego sygnału, który dezorientuje wyszukiwarki i przepala crawl budget. Google musi przetworzyć hreflang, spróbować wejść na URL, odkryć że jest zablokowany i wyrzucić wynik do kosza. Pomnóż to przez każdą stronę w serwisie.

Problem 3: brak noindex jako siatki bezpieczeństwa

Gdyby jakiś crawler jednak zignorował robots.txt (a to się zdarza — robots.txt to sugestia, nie egzekwowana reguła), na stronach ograniczonych języków nie było żadnego <meta name="robots" content="noindex, nofollow">. Całą ochronę stanowiła jedna linijka w robots.txt.

Problem 4: logika ograniczeń była rozsiana po kodzie

Ograniczenie AR/PT istniało dokładnie w dwóch miejscach:

  1. LanguagePicker.vue — lokalna stała HIDDEN_LANGUAGES
  2. nuxt.config.ts — zahardkodowane /ar/ i /pt/ w tablicy disallow dla robots

Te dwa miejsca nie były ze sobą w żaden sposób powiązane. Gdyby ktoś dołożył trzeci ograniczony język, musiałby wiedzieć o obu plikach. A żaden z nich i tak nie wpływał na sitemapy, tagi hreflang ani meta robots.

Problem 5: globalny prefetch przypadkiem wrócił do gry

Przy migracji z Nuxta 3 na Nuxta 4 z nuxt.config.ts wypadło globalne ustawienie experimental.defaults.nuxtLink.prefetch: false. Nasz własny komponent CustomNuxtLink nadal miał prefetch: false, ale każdy NuxtLink renderowany wewnętrznie przez moduły Nuxta (content, biblioteki UI) znowu prefetchował domyślnie — odpalając dodatkowe wywołania funkcji serverless przy każdym najechaniu myszką.

Poprawka: jedna stała, by wszystkimi rządzić

Rozwiązanie okazało się zaskakująco proste. Stworzyliśmy jedną stałą RESTRICTED_LANGUAGES jako źródło prawdy:

// global.constant.ts
export const RESTRICTED_LANGUAGES: readonly Language[] = ['ar', 'pt'] as const

A potem wpięliśmy ją w każdy system, który powinien wiedzieć o ograniczonych językach:

Sitemapy — filtruj przed generowaniem

const languages = (useRuntimeConfig().public.i18n.locales ?? [])
  .map((option: any) => option.code) as Language[])
  .filter(lang => !RESTRICTED_LANGUAGES.includes(lang))  // One line added

Zastosowane we wszystkich 6 handlerach sitemap. Natychmiastowa likwidacja ~5000 zmarnowanych URL-i.

Hreflang — filtruj wyjście

useHead({
  link: () => (i18nHead.value.link || []).filter(link =>
    !RESTRICTED_LANGUAGES.some(lang =>
      link.hreflang === lang || link.href?.includes(`/${lang}/`)
    )
  ),
})

Każda strona wypuszcza teraz tylko 11 produkcyjnych tagów hreflang zamiast 13.

Meta robots — noindex jako siatka bezpieczeństwa

const isRestrictedLanguage = computed(() =>
  RESTRICTED_LANGUAGES.includes(locale.value as typeof RESTRICTED_LANGUAGES[number])
)

useHead({
  meta: [
    ...(isRestrictedLanguage.value || hasFilterParams.value
      ? [{ name: 'robots', content: 'noindex, nofollow' }]
      : []
    )
  ]
})

Robots.txt — dynamicznie ze stałej

// nuxt.config.ts
disallow: [
  ...RESTRICTED_LANGUAGES.map(lang => `/${lang}/`),
  // ... other disallowed paths
],

Przełącznik języków — wspólna stała

// Before: local constant with no connection to other systems
const HIDDEN_LANGUAGES = ['ar', 'pt'] as const

// After: shared constant, single source of truth
import { RESTRICTED_LANGUAGES } from '~/constants/global.constant'

Prefetch — przywracamy globalne wyłączenie

// nuxt.config.ts
experimental: {
  defaults: {
    nuxtLink: {
      prefetch: false,
    },
  },
},

Rezultat

Co się zmieniłoPrzedPo
Zmarnowane URL-e w sitemapach~50000
Tagi hreflang na stronę13 (2 dla zablokowanych języków)11 (same produkcyjne)
noindex na stronach ograniczonychBrakJest
Miejsca do zmiany przy dodaniu lub usunięciu ograniczonego języka2 lub więcej (łatwo przeoczyć)1 stała
Ochrona przed prefetchemTylko na poziomie komponentuGlobalna plus komponent

Cała zmiana to 10 plików, 53 linie dodane, 27 usuniętych. Większość roboty stanowił ten sam jednolinijkowy filtr powtórzony w 6 handlerach sitemap.

Dlaczego to ma znaczenie dla kosztów na Vercelu

Każdy zbędny URL w sitemapie to potencjalne wywołanie funkcji serverless. Boty wyszukiwarek przechodzą sitemapy metodycznie — jeśli wylistujesz 5000 adresów prowadzących do zablokowanych stron, to 5000 wywołań funkcji, które zwracają jedynie odpowiedź wynikającą z reguły disallow. W modelu cenowym Vercela, gdzie płacisz za każde wywołanie, to się szybko sumuje.

Gorszy jest jednak koszt pośredni. Google przydziela każdej witrynie crawl budget. Każde żądanie zmarnowane na zablokowany URL to żądanie, które mogło zaindeksować prawdziwą, zarabiającą stronę. Jeśli 15% twojej sitemapy to śmieci, twoje realne treści są odwiedzane o 15% rzadziej.

Wnioski

1. „Zablokowanie” funkcji oznacza zablokowanie jej wszędzie. Ukrycie czegoś w UI i w robots.txt to za mało. Trzeba pomyśleć o sitemapach, hreflangu, meta tagach i każdym innym systemie, który wylicza twoje treści.

2. Rozsiana konfiguracja to dług. Dwa pliki z tymi samymi zahardkodowanymi wartościami to bug czekający na swoją okazję. Jedna stała przepływająca przez cały system jest łatwiejsza w utrzymaniu i nie da się jej zaktualizować połowicznie.

3. Sprzeczne sygnały SEO są gorsze niż brak sygnałów. Tagi hreflang wskazujące na adresy zablokowane w robots.txt aktywnie szkodzą. Wyszukiwarki marnują zasoby na przetwarzanie sprzecznych instrukcji, a twój crawl budget na tym cierpi.

4. Migracje frameworka potrafią po cichu zmienić domyślne ustawienia. Nasza migracja z Nuxta 3 na 4 zgubiła globalne prefetch: false. Aplikacja dalej działała, ale charakterystyka wydajności zmieniła się w sposób widoczny dopiero w metrykach produkcyjnych.

5. Monitoruj po każdym deployu. Wyłapaliśmy to, bo zauważyliśmy skok zużycia na Vercelu i regresję w Google Search Console. Bez monitoringu te problemy mogłyby siedzieć w kodzie miesiącami, po cichu paląc budżet i psując SEO.

Checklista dla serwisów wielojęzycznych

Jeśli prowadzisz wielojęzyczny serwis, w którym część języków jest w stanie „jeszcze nie gotowe”, sprawdź to wszystko:

  • Języki wykluczone z generowania sitemap
  • Brak tagów hreflang wskazujących na URL-e zablokowanych języków
  • <meta name="robots" content="noindex, nofollow"> na stronach ograniczonych języków
  • robots.txt blokuje ścieżki ograniczonych języków
  • Przełącznik języków ukrywa ograniczone języki przed użytkownikami (również w SSR)
  • Cała logika ograniczeń wypływa z jednej stałej albo z jednego configu
  • Globalny prefetch NuxtLink wyłączony (jeśli nie chcesz spekulatywnego ładowania)

Pomiń którykolwiek z tych punktów, a będziesz przeciekać wywołaniami serverless, marnować crawl budget albo wysyłać wyszukiwarkom sprzeczne sygnały.