Bir Vue SPA'yı arama motorlarına ve yapay zekâ tarayıcılarına görünür kılmak
Tek sayfalık bir uygulama, JavaScript çalıştırmayan bir tarayıcıya boş bir div gösterir. Bu sitede her rota build sırasında iki dilde gerçek HTML'e dönüşüyor; hreflang, JSON-LD, sitemap, robots.txt ve llms.txt aynı içerikten üretiliyor ve build sonunda kendini denetliyor.
01Boş div sorunu
Vue ile yazılmış bir SPA'nın sunucudan gelen HTML'i neredeyse boştur: bir <div id="app"> ve bir script etiketi. İçerik, tarayıcı JavaScript'i çalıştırdıktan sonra oluşur. Google bunu çoğu zaman render ediyor, ama yapay zekâ asistanlarının sayfa çekicileri ve pek çok önizleme botu sayfayı JavaScript çalıştırmadan okuyor. Onlar için bu site bir açılış ekranından ve boş bir kutudan ibaretti.
Seçeneklerden biri siteyi SSR odaklı bir framework'e taşımaktı. Bunun yerine mevcut Vue uygulamasını koruyup build'e bir prerender adımı ekledim. Sitenin bir sunucu tarafı yok; her sayfanın içeriği build anında belli. Bu durumda statik HTML dosyaları yeterli.
02Bir uygulama, iki giriş
Ortak nokta src/app/createSiteApp.ts. Sunucu tarafında ya da hidrasyon yapılacaksa createSSRApp, aksi halde düz createApp kullanıyor. Router sunucuda bellek geçmişiyle, tarayıcıda web geçmişiyle kuruluyor. İstemci girişi main.ts, #app içinde bir eleman olup olmadığına bakıyor: prerender edilmiş sayfada var, hidrasyon yapılıyor; geliştirme sunucusunda #app yalnızca bir yorum satırı içeriyor, uygulama sıfırdan kuruluyor.
const container = document.getElementById('app')!
const hydrate = !!container.firstElementChild
const { app, router } = createSiteApp({ server: false, hydrate })
app.use(createHead())
// wait for the first route (lazy views) before mounting
router.isReady().then(() => {
app.mount(container)
restoreSecret()
bootDone()
})router.isReady beklemesi önemli: görünümlerin çoğu tembel yükleniyor ve ilk rota çözülmeden mount edilirse istemcinin ürettiği ağaç sunucunun yazdığı HTML ile eşleşmez. Sunucu tarafında entry-server.ts içindeki render fonksiyonu aynı işi tersinden yapıyor: router.push ile adrese gidiyor, isReady bekliyor, renderToString ile HTML üretiyor ve bunu şablondaki <!--app-html--> yerine koyuyor. Head etiketleri unhead'in transformHtmlTemplate fonksiyonuyla birleştiriliyor. Aynı modül projeleri, yazıları ve dil dosyalarını da dışarı açıyor; build scriptleri içeriği ikinci bir kaynaktan değil, uygulamanın kendisinden okuyor.
03Her rota için bir dosya
Build komutu sırayla tip kontrolü, istemci build'i, SSR build'i, prerender ve SEO denetimi çalıştırıyor. scripts/prerender.mjs iki dil için bütün publicPaths listesini geziyor. Bu liste router.ts içinde projelerden ve yazılardan türetiliyor; yeni bir yazı eklemek, iki yeni HTML dosyası demek. 404 sayfası ayrıca, noindex ile yazılıyor.
dist/index.html → /
dist/work/yee-cms.html → /work/yee-cms
dist/en.html → /en
dist/en/journal/<yazı>.html → /en/journal/<yazı>
dist/404.html → noindex04İki dil, iki URL
Türkçe sayfalar kökte, İngilizce sayfalar /en altında. router.ts İngilizce rotaları Türkçe rotaların kopyası olarak üretiyor, adlarına en- ekliyor ve meta.lang değerini "en" yapıyor; her şeyi yakalayan 404 rotaları en sona konuyor. Dil URL'den okunuyor ve beforeEach içindeki setLocale çağrısı hem sunucuda hem tarayıcıda çalışıyor. Bu yüzden prerender edilen HTML ile hidrasyondan sonraki sayfa aynı dili konuşuyor.
useSeo her sayfaya kendini gösteren bir canonical ve tr, en, x-default için hreflang bağlantıları ekliyor; x-default Türkçe sayfayı gösteriyor. og:locale tr_TR ya da en_US oluyor, diğer dil og:locale:alternate olarak yazılıyor. 404 sayfası noindex aldığı için ne canonical ne de dil alternatifi taşıyor; arama motorlarını bir yere yönlendirmemesi gerekiyor.
05Makinelerin okuduğu katman
Her sayfada tek bir JSON-LD bloğu ve içinde bir @graph var. Person ve WebSite düğümleri her sayfada bulunuyor; Person'ın @id değeri sabit olduğu için bütün sayfalar aynı kişiye işaret ediyor. Bunlara sayfa türüne göre düğümler ekleniyor: yazılar için BlogPosting, alt sayfalar için BreadcrumbList, site haritası için CollectionPage. JSON, script etiketine yazılmadan önce < karakterleri kaçırılıyor; içerikteki hiçbir metin etiketi erken kapatamıyor.
sitemap.xml her adresi iki dilde listeliyor; her kayıtta tr, en ve x-default için xhtml:link alternatifleri ve sayfanın paylaşım kartı image:image olarak var. robots.txt herkese açık ve botları üç grupta adıyla anıyor: arama motorları (Googlebot, Bingbot, Applebot, DuckDuckBot, YandexBot), kaynak gösteren yapay zekâ arama ve asistanları (OAI-SearchBot, ChatGPT-User, Claude-SearchBot, Claude-User, PerplexityBot, Perplexity-User) ve eğitim tarayıcıları ile kontrol belirteçleri (GPTBot, ClaudeBot, Google-Extended, Applebot-Extended, CCBot). Hepsine açıkça Allow verilmiş; vazgeçmek istersem tek kelimeyi Disallow yapmam yetiyor. Dosyanın sonunda sitemap adresi duruyor.
llms.txt, llmstxt.org biçiminde düz markdown bir harita: kısa bir tanım, iki dilde sayfalar, projeler ve yazılar. llms-full.txt ise bütün yazıların ve proje notlarının tam metnini iki dilde, her biri kendi adresiyle veriyor; yazı blokları başlık, alıntı ve kod bloğu olarak markdown'a çevriliyor. Bu dosyaların hepsi scripts/seo-files.mjs içinde, uygulamanın kendi içerik modülünden üretiliyor.
SEO bir kere yapılan bir ayar değil, her build'de çalışan bir test olmalı.
06Build kendini denetlesin
Son adım scripts/check-seo.mjs. dist altındaki her HTML dosyasında tam bir <title> ve tam bir <h1> arıyor, <html lang> değerinin dosyanın yoluyla uyuştuğunu, og:locale değerinin dille eşleştiğini ve zorunlu meta setinin eksiksiz olduğunu kontrol ediyor. Açıklama 50 ile 170 karakter arasında olmalı. Canonical sitemap'te bulunmalı. hreflang bağlantıları kendini göstermeli, x-default Türkçe sayfaya gitmeli ve karşı dildeki sayfa da geri bağlantı vermeli. og:image ve twitter:image mutlak adres olmalı, dosya var olmalı ve 1200×630 olmalı. JSON-LD ayrıştırılabilmeli; BlogPosting, BreadcrumbList ve Person düğümlerinde gerekli alanlar bulunmalı. robots.txt sitemap'i duyurmalı. Bir sorun varsa build başarısız oluyor.