Type to search pages and sections.

Skip to content
Ginko

SEO and agents

What Ginko does for search engines and AI agents. Head tags, JSON-LD, sitemap, robots.txt, redirects, llms.txt, Markdown pages, the docs MCP endpoint and search for your own site.

What you get without code

The Nuxt module serves these addresses for every site. You write no code for them:

AddressContent
/sitemap.xmlEvery page and entry with an address, in every locale
/robots.txtCrawling rules, with the sitemap
/llms.txtAn index of the site for AI agents
/llms-full.txtAll entries of the main locale as Markdown in one file
<page>.mdOne entry as Markdown

A file of the same name in public/ wins: your own public/robots.txt or public/llms.txt replaces Ginko's.

Head tags and JSON-LD

Call useGinkoSeo once per page. It sets the title, the description, Open Graph and Twitter tags, and JSON-LD. ginko-i18n adds the canonical URL, hreflang with x-default, and <html lang>. The options are on Reading content.

The site-wide defaults go in seo in ginko.config.ts:

ts
// ginko.config.ts
import { defineSite } from '@lupinum/ginko'

export default defineSite({
  site: 'linde',
  name: 'Linden Inn',
  url: 'https://linde.example',
  timeZone: 'Europe/Vienna',
  locales: ['en'],
  currency: 'EUR',
  pages: { home: '/' },
  bricks: {},
  seo: {
    titleTemplate: '%s · Linden Inn',    // %s is the page title
    description: 'Country inn with a garden.',
    color: '#1e4032',                  // brand colour of the generated share cards
    image: '/og.png',                  // share image without nuxt-og-image
    organization: { logo: '/logo.png', sameAs: ['https://www.instagram.com/linde'] },
  },
})

A title that already names the site does not need the template: useGinkoSeo({ title: 'Linden Inn in Hallstatt', titleTemplate: false }) uses it as written.

The home page gets WebSite JSON-LD, and with seo.organization also Organization. Every other kind you pass yourself with jsonLd, for example an event:

ts
const route = useRoute()
const event = useGinkoEntry('events', () => route.params.slug)
useGinkoSeo(() => ({
  title: event.value.data.title,
  type: 'article',
  jsonLd: [{
    '@context': 'https://schema.org',
    '@type': 'Event',
    name: event.value.data.title,
    startDate: event.value.data.date,
  }],
}))

For a breadcrumb trail, breadcrumbJsonLd from @lupinum/ginko builds a BreadcrumbList from steps with title and path, from the home page to the page itself. The nodes of useGinkoBreadcrumbs fit as they are; the site's address makes the paths absolute:

ts
import { breadcrumbJsonLd } from '@lupinum/ginko'

const { url } = useGinkoSite()
const crumbs = useGinkoBreadcrumbs(['pages'])
useGinkoSeo(() => ({
  title: page.value.data.title,
  jsonLd: [breadcrumbJsonLd(crumbs.value, url)],
}))

The preset @lupinum/ginko-preset-gastro brings JSON-LD for restaurants, menus, opening hours and events (see Presets).

Sitemap

/sitemap.xml lists every page from pages and every visible entry that has an address, in every locale. Each address names its translations (hreflang, with x-default). Entries carry lastmod from their last change and up to 20 images.

A page that should stay out of search, for example the thank-you page after a form, goes in seo.noindex with its key from pages. It then leaves the sitemap and answers with X-Robots-Tag: noindex in every locale:

ginko.config.ts
pages: { thanks: { de: '/danke', en: '/en/thanks' } },
seo: { noindex: ['thanks'] },

With @nuxtjs/sitemap (on its own or through @nuxtjs/seo), that module owns /sitemap.xml. Ginko gives it the same list as a source (/__ginko/sitemap.json) and turns off its page discovery (excludeAppSources: ['nuxt:pages']), which would list folders that are no pages. Your own sources in sitemap add to Ginko's.

robots.txt

On the site's own address (url in ginko.config.ts), /robots.txt allows everything and names the sitemap. www. counts as the same address. On every other host, for example a preview deployment, staging or localhost, it forbids everything, and every response carries X-Robots-Tag: noindex. So a preview never lands in a search engine. With @nuxtjs/robots that module serves /robots.txt, and the same rule holds: Ginko marks every other host as not indexable for it.

Nuxt SEO

Ginko needs none of the Nuxt SEO modules. A site may install them anyway (Nuxt 4.6.1 or later), all at once with @nuxtjs/seo or one by one, in any order in modules. Then Ginko leaves each module its output and hands it the site's data:

ModuleWhat Ginko hands it
nuxt-site-configAddress, name, description and main language. Per request: the page's language, and "not indexable" on every host other than url
@nuxtjs/sitemapEvery page and entry with translations, lastmod and images (see Sitemap)
@nuxtjs/robotsThe sitemap; preview hosts stay closed (see robots.txt)
nuxt-schema-orgThe page's JSON-LD joins the module's graph (read once, when useGinkoSeo runs), and seo.organization becomes the identity. The module adds WebSite and WebPage
nuxt-seo-utilsGinko's title, canonical URL, og:url and <html lang> win over the module's defaults, so nothing appears twice
nuxt-og-imageGinko's share card (see Reading content)
nuxt-ai-readyOff: Ginko serves /llms.txt and the Markdown pages itself. With agents: { enabled: false } the module stays on

Your own settings in nuxt.config.ts win over everything Ginko hands over. A list you set yourself, such as sitemap.excludeAppSources or robots.sitemap, replaces Ginko's; sitemap.sources adds to Ginko's source. A module you turn off (sitemap: false) stays off.

Redirects

A moved page answers with a redirect, by default 301. There are three sources:

  • An entry names its old addresses in redirectFrom. See Content from files.
  • On the platform, the site owner changes a page's address in the Studio ("Change" next to the address). If the page was already online, the old address forwards to the new one and stays reserved, so no other entry can take it.
  • The site lists rules in redirects:
ts
// ginko.config.ts
export default defineSite({
  // …
  redirects: [
    { from: '/speisekarte', to: '/menu' },
    { from: '/events/:slug', to: '/whats-on/:slug' },
    { from: '/old-blog/*', to: '/blog/:splat', status: 302 },
  ],
})

:name matches one path segment. A * at the end matches the rest, which to uses as :splat. The first matching rule wins, and the query string stays. An invalid rule stops the build.

Text for AI agents

Agents read Markdown better than HTML. So every entry of a collection with a route also exists as Markdown:

  • <page>.md: /en/docs/languages.md returns the page /en/docs/languages as Markdown.
  • Only the .md address answers with Markdown. The normal address of a page always answers with HTML, whatever its Accept header asks for: a host cache such as Vercel's keeps one version per address, so one request for Markdown would otherwise serve Markdown to every visitor.
  • Link header: pages of file entries name their Markdown version (rel="alternate"), so an agent finds it.
  • Not found: a .md address without an entry answers with 404 in Markdown and suggests similar pages.
  • A dot in the slug (/blog/v1.2) changes nothing: the Markdown is at /blog/v1.2.md, and the Link header works as on any page.

/llms.txt lists the entries of the main locale with links to their Markdown. Under the site name, it shows agents.description, else seo.description. /llms-full.txt holds the text of all these entries in one file.

ts
// ginko.config.ts
export default defineSite({
  // …
  agents: { description: 'Opening hours, menu and events of the Linden Inn.' },
})

agents: { enabled: false } turns off all of it: /llms.txt, /llms-full.txt and the Markdown versions.

agents: { review: true } makes every agent that writes through the platform write a proposal that needs a yes from an owner or admin (see Platform and CLI).

The docs MCP endpoint

A docs site with @lupinum/ginko-docs also answers at POST /__ginko/docs-mcp. This is an MCP server (Streamable HTTP) with two read-only tools:

  • search_docs(query, locale?): the best matching pages and sections, with path and excerpt,
  • read_doc(path): one page as Markdown.

An agent connects with the address, for example in Claude Code:

Terminal
claude mcp add --transport http my-docs https://docs.example.com/__ginko/docs-mcp

The endpoint needs no key and shows only what the public site shows. It needs a server: a static build (nuxt generate) has no endpoint.

Addresses and headers

Agents, scripts and crawlers may rely on these addresses and headers. Until 1.0, a stable one changes its name or meaning only in a minor release, and the changelog names the change.

Address or headerWhat it is
<page>.mdThe entry as Markdown, Content-Type: text/markdown; charset=utf-8. Without an entry: 404, also in Markdown.
Link: <page.md>; rel="alternate"; type="text/markdown"On pages of file entries.
/llms.txt, /llms-full.txtThe entries of the main locale: a list with links, and the full text.
/sitemap.xml, /robots.txtSee Sitemap and robots.txt.
X-Robots-Tag: noindexOn every host other than url, on every address under /__ginko/, and on the pages in seo.noindex.
/__ginko/search/<locale>.jsonThe search index (see below). Its format is SearchIndexJson of @lupinum/ginko/search.
/__ginko/docs-mcpThe MCP endpoint of a docs site.
/_ginko/img/<hash>.<ext>Images next to content files, cached for a year. The hash comes from the content: treat it as opaque, and take the address from the entry, never build it yourself.

Everything else is internal and can change in any release: the other addresses under /__ginko/ (such as the preview and /__ginko/files/) and the headers x-ginko-rev, x-ginko-min-rev and x-ginko-site-token. The full list is in Internal, do not use.

Search for your own site

Ginko makes a search index per locale: one record for the text before the first ## heading of an entry, and one for each ## and ### section. The site serves it at /__ginko/search/<locale>.json. When the site has only file bricks, the build makes it. When it has platform bricks, the site's server builds it from the same content as the pages, without expired entries, and renews it like a page: what is published in the Studio is found within a minute. The docs layer uses it for its ⌘K search. In your own site, you read it with @lupinum/ginko/search:

vue
<!-- app/components/SiteSearch.vue -->
<script setup lang="ts">
import type { SearchHit, SearchIndex, SearchIndexJson } from '@lupinum/ginko/search'

const { locale } = useI18n()
const query = ref('')
const hits = ref<SearchHit[]>([])
const indexes = new Map<string, SearchIndex>()

watch(query, async (text) => {
  // Load the search engine and the index only when someone searches.
  const { loadIndex, search } = await import('@lupinum/ginko/search')
  let index = indexes.get(locale.value)
  if (!index) {
    index = await loadIndex(await $fetch<SearchIndexJson>(`/__ginko/search/${locale.value}.json`))
    indexes.set(locale.value, index)
  }
  hits.value = search(index, text, { limit: 8 })
})
</script>

<template>
  <input v-model="query" type="search" aria-label="Search">
  <ul>
    <li v-for="hit in hits" :key="hit.id">
      <NuxtLink :to="hit.url">{{ hit.heading ?? hit.title }}</NuxtLink>
      <p>{{ hit.excerpt }}</p>
    </li>
  </ul>
</template>

search finds words by their beginning and accepts small typos. A hit has id, url (with the #anchor of its section), title, heading, excerpt and score. Each index follows its language. German, English, French, Italian, Spanish, Dutch and Portuguese find other forms of a word ("connecting" finds "connected", "Gerichte" finds "Gericht"). Other languages match words by their beginning. Accents do not matter: "cafe" finds "café", and in German "ae" finds "ä". Content from the platform is not in this index.