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-8Dane 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>, sensownealt-y. Model czyta strukturę, nie tylko tekst. /llms.txtgenerowany z treści, z poprawnymcharset=utf-8.- schema.org:
BlogPosting(zinLanguagei datami),PersonzknowsAbout,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.