website-seo-geo
The on-page SEO + GEO contract for a new site: exact metadata limits (title 50–60 chars, meta description 140–160 with a 120 floor), og:title/og:description == title/desc, twitter inherits OG, clean c
SKILL.mdskills/website-seo-geo/SKILL.md
GitHubWebsite SEO & GEO
The metadata contract every page must satisfy. This skill owns the exact
limits and consistency rules; it hands depth off to the existing global skills:
ai-seo (AI answer-engine / GEO strategy), schema-markup (rich JSON-LD),
seo-audit (technical SEO / crawl / Core Web Vitals — see also website-qa).
The limit table (enforced)
| Tag | Ideal | Hard cap | Why |
|---|---|---|---|
<title> |
50–60 chars | ~60 | Google truncates ~580–600px; >60 risks a cut, >70 almost always cut |
<meta description> |
140–160 chars | ~160 | Truncates ~920px; >160 cut; <120 wastes space / keyword room |
og:title / og:description |
= <title> / = description |
— | Test fails on any divergence |
twitter:title / twitter:description |
inherit OG (same strings) | — | twitter:card = summary_large_image |
The test counts characters (em-dash, en-dash, curly apostrophe each = 1 char but
render wider — stay clear of the cap). <meta name="description"> is separate
from og:description; one does not replace the other — ship both, in sync.
German (and other compounding languages) eat the budget faster
The limits above are pixel-render caps, not word counts — they don't change per
language, and the numbers in the table stay exactly as specified. But German
compound nouns (Suchmaschinenoptimierung, Barrierefreiheit,
Barrierefreiheitsprüfung) pack far more meaning per character into a single
unbroken word than English does, so the same 50–60 / 140–160 char cap holds
noticeably less content once translated. Write for it deliberately:
Front-load the key term. German syntax tends to push the important noun toward the end of a compound or clause; if you translate an English title 1:1 the keyword can land past where truncation cuts. Put the primary keyword/term first, then qualify it, rather than burying it in a trailing compound.
When one natural compound blows the budget, don't force it — restructure. If
Barrierefreiheitsprüfung(24 chars alone) doesn't leave room for anything else, reach for a shorter near-synonym or split brand/category from descriptor with a colon or dash:Website-Builder: barrierefrei & SEO-optimiertreads better and fits, where one giant compound noun forces an awkward truncation or abbreviation.Budget for ~10–15% fewer effective words of meaning than an English title/description of the same character count. Plan the German copy's content around that from the start — don't write the English version, translate it, and then trim words until the character count fits; that's how you end up with a mangled abbreviation instead of a deliberate, readable title.
Target the LOWER half of the title range (~50–55 chars) for German. Google truncates by pixel width (~580px), not characters — German capitalizes every noun, and capitals plus umlauts render wide, so a passing 60-char German title can still be cut in the SERP. The 60-char cap is a ceiling, not a safe harbor.
This is a writing tactic, not a test change: seo.spec.ts enforces the same
character counts regardless of language, and it should — the fix here is how you
write within that budget, not the budget itself.
Consistency rules
- One pair of inputs
(title, description)drives<title>, OG, Twitter, canonical and the WebPage schema — wired once in the new-website skill'stemplates/astro/src/layouts/Base.astro, so pages only passtitle/descriptionand the tags can never drift. - Canonical is clean (no
.html), absolute, and equalsog:urland the page'ssitemap.xml<loc>. - No trailing slash (convention): URLs are
/about, not/about/(home stays/). The starter setstrailingSlash: 'never'+build.format: 'file'; write internal links without a trailing slash.navigation.spec.tsflags any/about/link (404/redirect hop) andseo.spec.tspinscanonical == SITE.url + path, so the whole site stays consistent. - Exactly one
<h1>per page (its text MAY differ from the keyword-tuned title). - ASCII slugs only: transliterate German umlauts/ß to digraphs (
/ueber-uns, not/über-uns) — machine-serialized URLs (canonical, sitemap) percent-encode non-ASCII while hand-written strings (PAGES, llms.txt, authored links) stay raw, a mismatch factory (site-architecture has the full rule). - og:locale derives from
<html lang>(de → de_DE; regioned tags → base+region; one sharedogLocaleFor()insrc/config.ts, emitted by Base.astro, asserted byseo.spec.tswhenever a value derives — mapped bases and all regioned tags) — a German page silently carryingog:locale en_USis exactly the drift this row exists to stop.
Schema (JSON-LD) — minimum per page
- Every page:
WebSite+Organization(once, site-wide) and aWebPagenode. - Add page-type schema via
schema-markup:BreadcrumbListon nested pages,FAQPageon FAQs,Article/TechArticlewith author@id+ dates on posts,Service/Productas relevant. Author/Organization@ids andsameAsfeed EEAT (seewebsite-content-guide); keepsameAsURLs that actually resolve. FAQPagedescribes the Q&A to machines, not a Google rich result. Since August 2023 Google shows FAQ rich results only for well-known, authoritative government and health sites (Google); most sites get none. Don't promise one.- Datetimes in JSON-LD carry an explicit timezone.
VideoObject.uploadDate,Event.startDate/endDate,Offer.validFrom,Articledates etc. must be full ISO-8601 with an offset (2026-03-11T00:00:00+00:00, not a bare2026-03-11). Google's video parser rejects a date-onlyuploadDateas "missing a timezone" / "invalid datetime value" — a GSC error that only surfaces weeks after launch. - Validate with Google Rich Results Test / schema.org validator before launch.
GEO (generative engine optimization)
- Ship
/llms.txt(template in the starters): a curated, accurate index of key pages + facts for AI answer engines. Write it in the site's primary content language (German site → German descriptions; multilingual site → each entry's description in that page's language) — same precedent as the privacy page: an answer engine quoting your English blurb to a German searcher is the wrong-language failure again. Keep it in sync with the site — route coverage is enforced both ways in the new-website starter suite:tests/llms-coverage.spec.tsfails if anyPAGESroute is missing from it, or if a same-site entry goes stale (page removed/renamed). Add the page's- [Title](URL): descriptionline in the same commit as the page; deliberately-hidden pages (paid-ad landers) go in itsLLMS_EXEMPTwith a reason. On a site that predates the guard, copy the spec in from the starter before relying on it. - For deeper GEO/AEO (being cited by ChatGPT/Perplexity/AI Overviews, answer
formatting, entity coverage), run
ai-seo— do not duplicate it here.
PDFs you host (the doc-title IS the search-result title)
A PDF has no <title> — Google and Bing print its embedded doc-title as
the search-result title. Authoring tools ship a placeholder
("PowerPoint-Präsentation", "Microsoft Word - report.docx") or a working-filename
slug, which Bing flags as "page title too short" (seen in production on a real
site built with this kit, 2026-06-19). So any PDF you put in public/ needs a
descriptive doc-title set before it ships:
- Title convention: the same human title the page uses for that document —
e.g.
Talk Title — Speaker | Brand. ≥15 chars, never the authoring placeholder. - Set it in BOTH places crawlers read — the Info dict
/Titleand the XMPdc:title. Fixing only one leaves the stale title visible. Use the helper:
(needspython3 scripts/set_pdf_title.py public/<file>.pdf "Descriptive Title | Brand" --author "Author"pip install pypdf; local authoring tool only — the test below is dep-free.) - Enforced by
seo.spec.ts: the PDF guard scanspublic/**/*.pdfand fails the build on a placeholder, a title under 15 chars, or no doc-title at all. No-op for sites that host no PDFs. - After fixing a live PDF, re-submit its URL in Bing/Google so it recrawls.
Done means
npm test green — specifically seo.spec.ts: every title/description within
limits, og/twitter/canonical in sync, JSON-LD parses, a sitemap exists, and every
hosted PDF carries a descriptive doc-title (not an authoring placeholder).
Judgment pass (what the tests cannot check)
seo.spec.ts proves the metadata is well-formed; it cannot tell you it is good.
Once it is green, do a one-page judgment read — this is a model call (Rule 5: use the
model only where code can't answer):
- Title matches search intent, not just ≤60 chars — would a searcher click it over its SERP neighbours?
- Description earns the click — answers the query, not keyword-stuffed.
- Schema is semantically right — correct
@typeand real relationships, not merely valid JSON (a valid-but-wrong@typestill passes the test). llms.txtis accurate vs the live site — facts and wording current, no drift (route coverage is the mechanical half, pinned bytests/llms-coverage.spec.ts; this read is about the wording half).- EEAT signals resolve —
sameAs/ author URLs actually load and corroborate.
Intent and semantics only. Structural correctness is the test's job; cross-file
route/URL/config/SEO consistency is the website-review (Double-Knuth) skill's job.