Після того як у ICanUp з'явилися локалізовані public routes, одного правильного URL для сторінки вже було недостатньо.
Один Post має спільний slug, але доступний як окрема українська та англійська сторінка. Потрібно було пояснити search engine дві речі одночасно: який URL є canonical для поточної мовної версії і які інші URLs є її перекладами.
Саме тут у моїй SEO infrastructure зустрілися canonical, hreflang, x-default і Laravel localization routing.
Ця стаття продовжує серію Laravel SEO Infrastructure. Раніше я вже розбирала автоматичну відправку URL через IndexNow і locale-aware lastmod у sitemap.
Проблема почалася з двох правильних URL однієї сутності
Після переходу на спільний slug структура Post у ICanUp стала дуже простою.
Postslug: laravel-indexnow ukhttps://example.com/posts/laravel-indexnow enhttps://example.com/en/posts/laravel-indexnowНа рівні domain це той самий Post і той самий canonical slug.
Але для search engine це два окремі public URLs із різним localized content.
Тому мені не можна було просто вибрати український URL як canonical для обох сторінок. Це зруйнувало б саму модель окремих мовних версій.
Перша важлива річ: canonical не повинен вказувати всі locale на default URL
Неправильно uk pagecanonical → /posts/laravel-indexnow en pagecanonical → /posts/laravel-indexnowУ моїй моделі правильно uk pagecanonical → /posts/laravel-indexnow en pagecanonical → /en/posts/laravel-indexnowКожна мовна версія отримала свій self-canonical
Для української сторінки canonical формується з українського public route.
Для англійської - з англійського.
У ICanUp українська є default locale, тому її URL не має prefix. Англійська використовує /en.
Це правило належить localization infrastructure, а SEO layer лише використовує вже правильно сформовані public URLs.
<link rel="canonical" href="https://example.com/posts/laravel-indexnow"><link rel="canonical" href="https://example.com/en/posts/laravel-indexnow">Той самий locale-aware URL я використовую і там, де потрібен canonical public address сторінки, наприклад для `og:url`.
А hreflang описує зв'язок між мовними версіями
Canonical відповідає на питання який URL є основним для цієї конкретної сторінки.
Hreflang відповідає на інше питання: які інші public URLs є мовними версіями цього самого content.
Тому на обох locale pages я генерую однаковий набір alternate links, але canonical у кожної сторінки свій.
<link rel="alternate" hreflang="uk" href="https://example.com/posts/laravel-indexnow"> <link rel="alternate" hreflang="en" href="https://example.com/en/posts/laravel-indexnow">Canonical визначає поточну сторінку. Hreflang визначає її мовних сусідів.
Спільний slug сильно спростив цей contract
Раніше в проєкті залишалася legacy-ідея translated slugs.
Під час інтеграції свого пакету avn/laravel-localization я перевела Posts і Categories на один product-owned slug для всіх локалей.
Тепер мовні URLs відрізняються route prefix, а не значенням slug. Це прибрало ще одне джерело складності з canonical, hreflang, sitemap, RSS та language switcher.
sluglaravel-indexnow uk route/posts/laravel-indexnow en route/en/posts/laravel-indexnowURL я не стала складати вручну
Окремо я хотіла уникнути конструкцій на кшталт якщо locale en - додати /en.
Мод префікса локалі за замовчуванням, увімкнені локалі та варіанти локалізованих маршрутів вже знають рівень локалізації.
Тому hreflang generation використовує чинну інфраструктуру маршрутизації з урахуванням локалізації.
SEO layer не повинен мати свою другу версію правил локалізації URL.
Post / Page / Category ↓localized route generation ↓canonical public URLs ↓SEO layer ↓canonical + hreflangДля x-default я використала default locale
У ICanUp default locale - uk.
Тому x-default вказує на canonical URL default locale. Він не створює третю сторінку і не має окремого route.
Це просто ще один alternate signal для вже існуючого default URL.
<link rel="alternate" hreflang="x-default" href="https://example.com/posts/laravel-indexnow">Цікаво, що для hreflang повтор default URL під `uk` і `x-default` має різну семантику, тому обидва links потрібні. А в моєму IndexNow URL batch `x-default` навпаки не додається окремим URL, бо там потрібен список реальних унікальних targets.
Недоступну мовну версію не можна рекламувати через hreflang
Ще один важливий edge case з'явився з Pages.
Наявність locale в системі не означає автоматично, що конкретна Page має доступну public version цією мовою.
Тому для content, де translation може бути відсутнім, hreflang повинен описувати реально доступні localized pages, а не генерувати URL, який приведе до 404 або неявного fallback.
Неправильно Page has only uk public content hreflang uk → validhreflang en → generated anyway → 404Правильно Page has only uk public content hreflang uk → validhreflang en → not exposedОдин SEO контракт працює для різних public content types
Після цього я не хотіла створювати окрему hreflang implementation для кожного нового public module.
Posts, Categories, Pages, а пізніше і Post Series повинні використовувати одну SEO infrastructure та свої existing public URL rules.
Відмінність лише в тому, які локалізовані варіанти реально доступні для конкретної entity.
Content | URL rule | hreflang rule |
|---|---|---|
Post | shared slug + locale route | доступні варіанти локалі |
Category | shared slug + locale route | доступні варіанти локалі |
Page | canonical Page slug + locale route | лише реально доступні переклади |
Post Series | shared public slug + locale route | доступні варіанти локалі |
Canonical і hreflang залишилися в спільному SEO layer
Ще одним принциповим рішенням було не переносити цю логіку в окремі Vue pages.
Public controller або presenter готує SEO контракт, існуючий SEO layer формує метадані, а frontend лише рендерить отримані значення.
Завдяки цьому Page, Post або Series не мають власної незалежної реалізації canonical/hreflang.
Public entity ↓public visibility + locale ↓canonical localized URLs ↓shared SEO metadata ↓canonicalhreflang ukhreflang enx-defaultПотім потрібно було переконатися, що ці tags реально є в HTML
У ICanUp SEO metadata серверно рендеряться через Inertia SSR.
Для search engine для мене недостатньо було побачити правильний canonical у DevTools після виконання JavaScript. Я хотіла, щоб canonical і alternate links були присутні вже в початковому server-rendered <head>.
curl -s https://example.com/posts/laravel-indexnow \ | sed -n '/<head>/,/<\/head>/p'<link rel="canonical" href="https://example.com/posts/laravel-indexnow"> <link rel="alternate" hreflang="uk" href="https://example.com/posts/laravel-indexnow"> <link rel="alternate" hreflang="en" href="https://example.com/en/posts/laravel-indexnow"> <link rel="alternate" hreflang="x-default" href="https://example.com/posts/laravel-indexnow">Автоматичними tests я зафіксувала саме URL behavior
Alternate URL існує для кожної підтримуваної та доступної locale.
Default locale URL не отримує зайвий prefix.
English URL використовує /en.
Post і Category використовують shared canonical slug.
Legacy translated slugs не потрапляють у нові hreflang URLs.
x-default вказує на canonical URL default locale.
Disabled locale не потрапляє в metadata.
Недоступна translation не створює broken alternate URL.
Canonical відповідає активній locale.
Existing SEO metadata не регресує.
Помилки, яких я тепер уникаю
Не ставити default-locale canonical на всі мовні версії.
Не використовувати hreflang URL з іншої slug architecture.
Не конкатенувати locale prefix вручну в SEO code.
Не додавати disabled locale.
Не генерувати alternate для translation, якої немає.
Не забувати x-default.
Не перевіряти SEO metadata тільки після client-side render.
Не створювати окремий hreflang service для кожного нового content module.
Коли з'явилися Post Series, нової SEO-системи вже не знадобилося
Це стало хорошою перевіркою архітектури.
Коли я додала public Post Series, для них уже можна було повторно використати той самий контракт: localized canonical, hreflang для uk та en, x-default, Open Graph і sitemap.
Тобто SEO infrastructure розширилася новим content type без появи паралельної Series-specific логіки.
Що я залишила собі на майбутнє
Canonical і hreflang вирішують різні задачі.
Кожна valid localized page може мати свій self-canonical.
Hreflang повинен використовувати ті самі URLs, що й public routing.
Default locale prefix rules не повинні дублюватися в SEO layer.
x-default може вказувати на той самий URL, що й default locale alternate.
Shared slug значно спрощує multilingual SEO contract.
Недоступні locale variants не потрібно вигадувати.
SEO metadata мають бути присутні в server-rendered HTML.
Пов'язані статті
Висновок
Коли я починала multilingual SEO, canonical і hreflang здавалися двома невеликими tags у <head>.
У реальному Laravel-проєкті головне виявилося не написати ці tags, а не створити для них окрему URL reality.
У ICanUp routing залишається джерело істини: shared slug належить content entity, localization layer формує правильний uk або en public URL, а SEO layer використовує ці URLs для self-canonical, hreflang і x-default.
Завдяки цьому language switcher, canonical, hreflang, sitemap, IndexNow і нові public content types говорять про одну й ту саму структуру сайту.
Для multilingual SEO я не хочу окремо будувати SEO URLs. Я хочу, щоб SEO точно описувало ті public URLs, які вже є правдою для застосунку.



