---
title: "seo skill: findable by search engines, AI engines and agents"
description: "SEO and GEO (AI search / answer-engine visibility) for websites. Covers server-rendered content, titles, descriptions and canonicals, Open Graph images…"
canonical: https://void-design.vercel.app/docs/seo
lastModified: 2026-09-16
---

# seo — findable by search engines, AI engines and agents

Classic SEO is the foundation of GEO. Nearly all measurable AI-search visibility comes from being indexed, snippet-eligible and having complete raw HTML.
Canonical implementations live in `templates/next/`: `src/site.config.ts` (facts), `src/content/pages.ts` (page registry), `src/lib/seo.ts` (`pageMetadata`), `src/lib/schema.ts` + `src/components/json-ld.tsx`, `src/app/{sitemap.ts,robots.ts,manifest.ts,opengraph-image.tsx}`, `src/app/llms.txt/route.ts`, `src/app/md/[...slug]/route.ts`, `next.config.ts` (`htmlLimitedBots`, `.md` rewrites). Copy from there. The references hold the same code for other projects.

## The 10 rules that matter most

1. **The raw HTML response is complete.** Main content in reading order, full `<head>`, JSON-LD and `<a href>` links, without running JavaScript. OpenAI, Anthropic, Perplexity and Meta crawlers don't execute JS.
2. **Exactly one `<title>`, `<meta name="description">` and absolute `<link rel="canonical">` in `<head>` of the raw HTML** on every indexable page. The canonical is self-referential and set per page, **never in the root layout**.
3. **Real HTTP status codes.** 200 for pages, 404 for unknown URLs (never a 200 "not found"), 301/308 for moves. In Next, call `notFound()` **before** any `<Suspense>`.
4. **Keep metadata static** (prerendered, or `'use cache'`). If a route must be request-time, add Googlebot and the AI bots to `htmlLimitedBots`, or Next streams title and canonical into `<body>`.
5. **One metadata helper** (`pageMetadata()`) for every page. Next merges metadata **shallowly**, so a page's `openGraph` silently drops the layout's `siteName`, `locale` and images.
6. **One `<h1>` per page** that matches the title's topic. Landmarks `<header>`, `<nav>`, `<main>`, `<footer>`; `<article>` for posts.
7. **Structured data describes only what is visible and true.** No invented ratings, reviews, prices or FAQs. One `@graph` with stable `@id`s. Escape `<` in JSON-LD.
8. **robots.txt separates training bots from retrieval bots.** Blocking `GPTBot` is a business choice. Blocking `OAI-SearchBot`, `Claude-SearchBot` or `PerplexityBot` removes you from AI answers. Previews send `Disallow: /`.
9. **Sitemap `lastModified` is the real content date**, never `new Date()` or the build time. Exclude noindex, auth and utility routes.
10. **Write to be quoted.** Answer first, then evidence: numbers with units and dates, links to primary sources, named quotes. Keep key facts visible, not hidden in tabs or images.

## The four readers (design for the weakest)

| Reader | Examples | Runs JS? | Needs |
|---|---|---|---|
| Rendering search crawler | Googlebot, Applebot | Yes, deferred | Raw `<head>` (canonical, robots) + rendered DOM |
| HTML-only index crawler | Bingbot (feeds Copilot and ChatGPT search), OAI-SearchBot, Claude-SearchBot, PerplexityBot | **No** | Everything in raw HTML |
| User-triggered fetcher | ChatGPT-User, Claude-User, Perplexity-User | No | Raw HTML; may ignore robots.txt |
| Agent / coding tool | Claude Code, Cursor, Codex | No | Clean HTML, `/llms.txt`, `.md` mirrors |

Test: `curl -sA "GPTBot/1.4" https://site/page | grep -c "<main"`. If the text isn't there, it doesn't exist for most AI search.

## Must-ship checklist per page type

| Page | Must emit |
|---|---|
| **Root layout** | `metadataBase`; `title: { default, template: "%s \| Brand" }`; description; `openGraph.siteName` + `locale`; `twitter.card: "summary_large_image"`; `robots.googleBot["max-image-preview"]: "large"`; `viewport.themeColor` (light/dark); `<html lang>`; landmarks; `<link rel="describedby" href="/llms.txt">`. **No canonical here.** |
| **Home** | `canonical: "/"`; Organization + WebSite in the `@graph` (on this page or emitted from the root layout); `opengraph-image`; one h1 naming what the product is and who it's for; first paragraph states it in 1–2 sentences |
| **Content page** (about, features, pricing) | `pageMetadata({ path, title, description })`; BreadcrumbList if nested; one h1; descriptive h2s; comparison `<table>` where relevant |
| **Article / blog post** | `pageMetadata({ type: "article", publishedTime, modifiedTime })`; BlogPosting + BreadcrumbList; visible byline + `<time dateTime>` "Updated" date matching JSON-LD; per-post `opengraph-image`; `.md` mirror; `generateStaticParams` + `dynamicParams = false` or `notFound()` before Suspense |
| **Product / app / pricing** | Product (buyable goods) or SoftwareApplication with the real, visible price. Add `aggregateRating` **only** when real ratings are shown on the page (without it SoftwareApplication is valid but not rich-result eligible). Prices in visible text |
| **Docs page** | TechArticle + BreadcrumbList; `.md` mirror advertised with `alternates.types["text/markdown"]`; listed in llms.txt; stable URLs; version in text |
| **Author page** | ProfilePage + Person with `sameAs`; linked from `Article.author.url` |
| **App / auth / search results** | `robots: { index: false, follow: true }`; not in sitemap, llms.txt or internal nav meant for crawlers; `/login` never canonicalizes to `/` |
| **Site-wide files** | `robots.ts` (preview guard + AI policy + `sitemap`), `sitemap.ts`, `llms.txt`, `manifest.ts`, `favicon.ico` **plus** `icon.png` (Google doesn't list SVG favicons), `apple-icon` 180×180, 404 page returning 404 |

## Metadata rules

- **Title:** unique, 10–60 chars, `Topic | Brand`. The brand name appears in every title; the home page uses an absolute tagline title (the helper does this for `path: "/"`). Don't put a plain string `title` in a nested `layout.tsx`: it resets the template for every child page.
- **Description:** unique, 50–160 chars, a summary sentence rather than a keyword list. Fill it on every page.
- **Canonical:** absolute (via `metadataBase`), no fragment, one URL form site-wide (https, one host, no trailing slash in Next's default). The same form in sitemap, internal links and hreflang. Never combine `noindex` with a cross-page canonical.
- **OG:** `og:title`, `og:type`, `og:image` (1200×630 PNG/JPEG, absolute, ≤ 8 MB, not SVG), `og:url` = canonical, `og:site_name`, `og:locale`. Use the file convention `opengraph-image.tsx` per section or post. It also fills `twitter:image`.
- **Viewport:** Next adds `width=device-width, initial-scale=1`. Never add `maximum-scale=1` or `user-scalable=no`.
- **`keywords` meta is useless.** Don't spend tokens on it.
- Don't set `nosnippet` or a low `max-snippet`; they also remove you from AI Overviews. Put `data-nosnippet` only on cookie banners and boilerplate.
- Uncompressed HTML < 1 MB (Googlebot stops at 2 MB, and the inlined RSC payload roughly doubles text weight).

Recipes: `references/next-metadata.md`.

## Next.js 16 traps (verified on 16.3.5)

| Trap | Symptom | Fix |
|---|---|---|
| **Streaming metadata.** A request-time `generateMetadata` (using `cookies()`, `headers()`, `connection()` or uncached fetches) streams `<title>`/canonical into `<body>` for every UA not in `htmlLimitedBots`. The default list has **no AI bots and no Googlebot**. Google only accepts canonical in `<head>`. Suspense content arrives in `<div hidden id="S:n">`, out of order | `curl -A GPTBot` shows `<link rel="canonical">` after `</head>` | Prefer static metadata (`generateStaticParams`, `'use cache'`). Otherwise extend `htmlLimitedBots` **and keep Next's default list in the regex** (setting it replaces the default). Recipe: `next-metadata.md` §3 |
| **`notFound()` inside Suspense → HTTP 200** + `<meta name="robots" content="noindex">` (soft 404) | Unknown slug returns 200 | Look up the entity at the top of `page.tsx`/`generateMetadata` and call `notFound()` before rendering any `<Suspense>`. Or `dynamicParams = false`. Same for `redirect()` |
| **Shallow merge.** A page's `openGraph` replaces the layout's whole `openGraph` (loses `siteName`, `locale`, `images`). A page's `alternates` drops the layout's `alternates.types` | `og:site_name` missing on subpages | Always build page metadata with `pageMetadata()`, which re-adds every field |
| **Async params.** `params`, `searchParams`, `generateSitemaps` `id` and `generateImageMetadata` `id` are Promises in 16 | Type errors or `undefined` | `const { slug } = await params` |
| **`middleware` → `proxy`.** The file is `proxy.ts` and exports `proxy` | Middleware silently not running | Rename |
| **`generateSitemaps` makes no index** | `/sitemap.xml` lists nothing for split sitemaps | Add a `sitemap-index.xml` route (`next-metadata.md` §6) |
| **`Vary: Accept` overwritten** on prerendered HTML | CDN serves Markdown to browsers when negotiating | Use explicit `.md` URLs, not `Accept` negotiation (`llms-txt-markdown.md`) |

## Structured data policy

- **Still yields Google rich results or knowledge features:** Organization, WebSite (site name), BreadcrumbList, Article/BlogPosting/NewsArticle, Product (snippet or merchant listing), SoftwareApplication (**requires `aggregateRating` or `review`**), ProfilePage, VideoObject, Event, QAPage/DiscussionForumPosting, Recipe, JobPosting, LocalBusiness.
- **Gone. Don't add for rich results:** FAQPage (not shown since 2026-05-07), HowTo (2023), sitelinks SearchAction (2024-11), plus ClaimReview, course info, estimated salary, vehicle listing, special announcement, practice problem.
- **Never fabricate.** Every `name`, `headline`, `price`, `ratingValue`, `author.name` must appear in the visible text. No self-serving reviews on your own Organization. "Only publish what exists."
- JSON-LD is server-rendered in raw HTML via a native `<script type="application/ld+json">` (not `next/script`), with `<` escaped as `\u003c`.
- Organization + WebSite: once per page graph, on the home page at minimum (the template emits them from the root layout). Other nodes reference `{"@id": "https://site/#organization"}`.
- Dates are ISO 8601 with a timezone. `dateModified ≥ datePublished`, matching the visible date.
- Types, required properties and a `@graph` builder: `references/structured-data.md`.

## robots.txt and AI policy

| Group | Tokens | Default |
|---|---|---|
| Search engines | `Googlebot`, `Bingbot`, `Applebot`, `DuckDuckBot` | Allow |
| AI **retrieval** (cites you in answers) | `OAI-SearchBot`, `Claude-SearchBot`, `PerplexityBot`, `DuckAssistBot`, `MistralAI-Index`, `Amzn-SearchBot`, `meta-webindexer` | **Allow** (blocking removes you from ChatGPT search, Claude and Perplexity answers) |
| AI **user fetchers** (a person asked) | `ChatGPT-User`, `Claude-User`, `Perplexity-User`, `MistralAI-User` | Allow (several ignore robots anyway) |
| AI **training** | `GPTBot`, `ClaudeBot`, `Google-Extended`, `Applebot-Extended`, `CCBot`, `meta-externalagent`, `Amazonbot`, `MistralAI-Training`, `Bytespider` | Owner's choice (`site.ai.training` in `site.config.ts`; template default: disallow). Blocking doesn't affect search or AI Overviews |

- A bot obeys **only its most specific group**. Repeat `Disallow: /api/` in every named group.
- Previews and staging: `Disallow: /` (keyed on `VERCEL_ENV` or equivalent). Production never.
- Don't disallow pages that carry `noindex`; crawlers must fetch them to see it. Don't block `/_next/`.
- `Google-Extended` doesn't remove you from AI Overviews. Only `noindex`/`nosnippet` does.
- Full crawler table + `robots.ts`: `references/ai-crawlers.md`.

## llms.txt and Markdown mirrors (cheap extras, `info` level)

- Ship `/llms.txt`: `# Brand`, a `> one-sentence summary`, curated `## Docs` / `## Blog` link lists pointing to `.md` URLs, and `## Optional`. It's under 100 KB and prerendered.
- It mainly serves **coding agents**, not answer engines: 97% of llms.txt files got zero requests in a 137K-domain study, and Google says it doesn't use them. Never claim it improves ranking. A 5xx is an error (Lighthouse fails it); a missing file is fine.
- Docs and posts: `page.md` mirror with `X-Robots-Tag: noindex` + `Link: <html-url>; rel="canonical"`, advertised with `<link rel="alternate" type="text/markdown">`. Not in the sitemap.
- Recipes: `references/llms-txt-markdown.md`.

## Content that gets cited

Evidence (KDD 2024 GEO study, NeurIPS 2025 C-SEO Bench, Microsoft and Google guidance): adding **citations, statistics and quotations** lifted AI visibility 30–40%, most for pages not ranked #1. Keyword stuffing and "authoritative tone" did nothing. AI engines favor third-party (earned) coverage.

1. **Answer first.** The first paragraph under the h1 and under each question-style h2 answers in 1–2 self-contained sentences (≤ 320 chars) that name the entity ("Acme is…", not "It is…").
2. **Evidence.** Numbers with units and dates ("cuts build time from 47 s to 4.5 s, measured Sept 2026"), links to primary sources, `<blockquote cite>` quotes from named people.
3. **Structure.** Descriptive h2/h3 (often questions users ask), `<ul>`/`<ol>` for steps, `<table>` for comparisons and specs, and paragraphs under ~150 words.
4. **Entity clarity.** The same brand and product name in title, h1, `og:site_name`, JSON-LD `name` and the llms.txt H1. A clear "what it is / who it's for / pricing / license" block.
5. **Freshness.** A visible "Updated" date that matches `dateModified` and sitemap `lastmod`. Update facts, not just dates.
6. **Visible.** Key facts are not only in images, video, PDFs, canvas, carousels or closed tabs. Use `<details open>` or plain sections.
7. **Non-commodity.** First-hand data, benchmarks, original examples, clear opinions. Near-duplicate pages get clustered and only one is used.

Don't: generate keyword-variant pages, rewrite content "for AI", chunk into micro-pages, add FAQPage for rich results, or fake mentions. Patterns and examples: `references/content-for-citation.md`.

## Verify

1. `void lint` (static): `lint/missing-metadata-base`, `lint/missing-metadata`, `lint/canonical-in-root-layout`, `lint/og-merge-drops-parent`, `lint/not-found-in-suspense`, `lint/dynamic-metadata-streaming`, `lint/page-level-use-client`, `lint/dangerously-set-jsonld-unescaped`, `lint/jsonld-deprecated-type`, `lint/sitemap-lastmod-now`, `lint/missing-sitemap`, `lint/missing-robots`, `lint/missing-og-image`, `lint/missing-icon`, `lint/missing-llms-txt`, `lint/missing-alt`, `lint/multiple-h1`.
2. `next build`, then `void seo --start "next start -p 3000" --port 3000 --format md`: status codes (`seo/http-status`, `seo/soft-404`), head placement per UA (`seo/metadata-in-body`), canonical (`seo/canonical-*`), titles/descriptions, h1, links, OG image fetch, icons, robots.txt, sitemap. Include an unknown URL in `--routes` (e.g. `/this-page-does-not-exist`) to prove 404s.
3. `void geo` (same flags): raw-vs-rendered content (`geo/js-dependency`, `geo/hidden-streamed-content`), JSON-LD (`geo/jsonld-parse-error`, `geo/jsonld-required`, `geo/jsonld-not-visible`, `geo/faq-deprecated`), AI robots (`geo/search-bots-blocked`, `geo/robots-ai-policy`), llms.txt (`geo/llms-txt-*`), Markdown mirrors (`geo/markdown-*`), content heuristics (`geo/answer-first`, `geo/no-citations`, info level).
4. **Gates must pass** (each caps the score at 49): `seo/http-status`, `seo/noindex`, `seo/canonical-missing`, `seo/canonical-invalid`, `seo/canonical-broken`, `seo/robots-blocks-all`, `seo/sitemap-url-status`, `geo/js-dependency`, `geo/jsonld-parse-error`. Targets: seo ≥ 95, geo ≥ 90. `info` GEO findings are review prompts, not failures.
5. Manual spot checks: `curl -sA "Googlebot/2.1" URL | head -c 3000`; Google Rich Results Test for new types; Search Console and Bing Webmaster Tools "AI Performance" after launch. Details: the `audit` skill.
