Przejdź do treści

llms.txt i GEO – jak przygotować stronę pod modele językowe

Wyszukiwanie zmienia się na naszych oczach. Zamiast listy niebieskich linków coraz częściej dostajemy gotową odpowiedź wygenerowaną przez model językowy – a ten odpowiada na podstawie tego, co zrozumiał z dostępnych stron. Optymalizacja pod ten scenariusz dorobiła się nazwy GEO (Generative Engine Optimization): to nie zaklinanie algorytmu słowami kluczowymi, tylko podanie treści w formie, którą maszyna łatwo i jednoznacznie przetworzy. W tym wpisie pokazuję, co konkretnie zrobiłem na brylka.net – z działającym kodem, nie ogólnikami.

GEO ≠ magia. To dyscyplina młoda i bez gwarancji. Większość rzeczy, które pomagają modelom, to po prostu dobre, czyste fundamenty: semantyczny HTML, dane strukturalne i przewidywalna mapa serwisu. Nic, czego nie powinno być na porządnej stronie.

Czym jest /llms.txt

/llms.txt to propozycja standardu (z llmstxt.org) – jeden plik w korzeniu domeny, który zwięźle, w Markdownie, opisuje zawartość serwisu dla silników AI. Idea jest taka sama jak przy robots.txt czy sitemap.xml, ale adresat jest inny: zamiast robota wyszukiwarki – model językowy, któremu dajemy gotową, uporządkowaną mapę „co tu jest i pod jakim adresem”.

Po co, skoro model i tak umie sparsować HTML? Bo strona to treść plus nawigacja, menu, stopka, skrypty i całe rusztowanie. /llms.txt to czysta esencja: tytuły, krótkie opisy i linki, bez szumu. Im mniej model musi zgadywać, tym wierniej zacytuje.

Generuję go z treści, nie ręcznie

Najgorszy plik /llms.txt, jaki możesz mieć, to taki, który raz napisałeś i o nim zapomniałeś. Po dwóch wpisach jest już nieaktualny. Dlatego u mnie powstaje automatycznie z kolekcji treści Astro – ten sam zbiór wpisów i projektów, z którego budują się strony. Jest więc zawsze zsynchronizowany:

// src/pages/llms.txt.js
import { getCollection } from 'astro:content';
import { SITE, BRAND, TAGLINE, AUTHOR } from '../lib/config.js';

export async function GET() {
  const posts = (await getCollection('posts', ({ data }) => !data.draft))
    .sort((a, b) => +b.data.date - +a.data.date);
  const projects = (await getCollection('projects'))
    .sort((a, b) => a.data.order - b.data.order);
  const url = (p) => new URL(p, SITE).href;

  const lines = [`# ${BRAND}`, '', `> ${TAGLINE}. Autor: ${AUTHOR.name}.`, ''];

  lines.push('## Blog');
  for (const post of posts) {
    lines.push(`- [${post.data.title}](${url('/' + post.id)}): ${post.data.description ?? ''}`);
  }

  return new Response(lines.join('\n'), {
    headers: { 'Content-Type': 'text/plain; charset=utf-8' },
  });
}

Efekt to czytelny Markdown z sekcjami Portfolio, Blog i Strony – każdy wpis jako - [tytuł](url): opis. Dodaję wpis, robię build i plik aktualizuje się sam. Możesz podejrzeć żywą wersję: /llms.txt.

Pułapka z kodowaniem. Plik .txt bywa serwowany bez charset, przez co polskie znaki zamieniają się w krzaczki. Na Cloudflare wymuszam UTF-8 wpisem w public/_headers:

/llms.txt
  Content-Type: text/plain; charset=utf-8

Dane strukturalne – druga noga GEO

/llms.txt to wygodna mapa, ale prawdziwy fundament maszynowego rozumienia treści to dane strukturalne schema.org (JSON-LD). One mówią modelowi (i Google) wprost: to jest artykuł, to jego autor, to data publikacji, a to wydawca. Zero zgadywania z układu strony.

Każdy wpis dostaje u mnie BlogPosting z jawnym językiem i datami:

const jsonLd = {
  '@context': 'https://schema.org',
  '@type': 'BlogPosting',
  headline: title,
  description,
  inLanguage: 'pl-PL',
  datePublished: date.toISOString(),
  dateModified: (updated ?? date).toISOString(),
  author: { '@type': 'Person', name: AUTHOR.name, url: SITE },
  publisher: organizationLd(),       // Organization + logo
  keywords: tags.join(', '),
};

Do tego dochodzą BreadcrumbList (okruszki – model widzi hierarchię), Organization z logo (wydawca) oraz WebSite. Wszystko składane z jednego źródła prawdy w src/lib/config.js, więc nie rozjeżdża się między stronami.

Sygnały eksperckie (E-E-A-T)

Modele i wyszukiwarki premiują treść, za którą stoi rozpoznawalny, kompetentny autor – to skrót E-E-A-T (Experience, Expertise, Authoritativeness, Trust). W praktyce wyrażam to encją Person z polem knowsAbout (lista realnych specjalizacji) i sameAs (profile zewnętrzne, które potwierdzają tożsamość):

{
  '@type': 'Person',
  name: 'Bartosz Bryniarski',
  jobTitle: 'Full Stack Developer',
  knowsAbout: ['Django', 'Symfony', 'React Native', 'Flutter', 'FreeBSD',
               'Cyberbezpieczeństwo', '...'],
  sameAs: ['https://github.com/brylka'],
}

To nie kosmetyka. knowsAbout daje modelowi jednoznaczny kontekst, w jakich tematach ta strona jest wiarygodnym źródłem, a sameAs spina ją z tożsamością poza serwisem.

Najlepsze GEO to dobra treść opisana prawdą. Dane strukturalne mają opisywać to, co realnie jest na stronie – nie obiecywać czegoś, czego nie ma. Rozjazd między schematem a treścią prędzej zaszkodzi niż pomoże.

Czeklista – co realnie pomaga

Jeśli miałbym to streścić do listy do odhaczenia:

  • Semantyczny HTML – nagłówki w hierarchii, <article>, <time>, sensowne alt-y. Model czyta strukturę, nie tylko tekst.
  • /llms.txt generowany z treści, z poprawnym charset=utf-8.
  • schema.org: BlogPosting (z inLanguage i datami), Person z knowsAbout, Organization, BreadcrumbList.
  • Jeden opis na wpis (description) – używany i w <meta>, i w OG, i w /llms.txt.
  • Stabilne, czytelne URL-e – bez śmieciowych parametrów; adres też niesie znaczenie.
  • Szybkość i dostępność – strona, która ładuje się od ręki i jest poprawna, jest też łatwiejsza do przetworzenia.

Większość tych punktów to po prostu higiena dobrej strony. I to jest chyba najważniejszy wniosek: GEO w 2026 nie jest osobną sztuczką obok SEO – to ta sama solidna robota u podstaw, tylko opisana w sposób, który rozumie i człowiek, i maszyna.

Jak ten stack wygląda od strony budowy, opisałem we wpisie Z WordPressa na Astro + Cloudflare Workers. A co na nim stoi w praktyce – w portfolio.