Decide which crawlers may read the store and what they may use it for, list every page in every language in the sitemap, and make sure the HTML each crawler gets carries its title, canonical and hreflang in head. Most AI crawlers don't run JavaScript, so what isn't in the served HTML doesn't exist for them.
Name the AI crawlers explicitly, in their own group. Several of them look for a group with their own user agent, and listing them makes letting them in (or not) a visible choice that is easy to change.
// app/robots.ts
import type { MetadataRoute } from 'next';
const PRIVATE = ['/api/', '/oauth/', '/*/cart', '/*/account'];
const AI_AGENTS = [
'GPTBot', 'OAI-SearchBot', 'ChatGPT-User',
'ClaudeBot', 'Claude-User', 'Claude-SearchBot',
'PerplexityBot', 'Perplexity-User',
'Google-Extended', 'Applebot-Extended',
];
export default function robots(): MetadataRoute.Robots {
return {
rules: [
{
userAgent: '*',
allow: '/',
disallow: PRIVATE,
// contentsignals.org: may be searched and used as live input to AI answers; training left unstated.
other: { 'Content-Signal': 'search=yes, ai-input=yes' },
},
{ userAgent: AI_AGENTS, allow: '/', disallow: PRIVATE },
],
sitemap: 'https://your-store.example/sitemap.xml',
};
}// app/robots.ts
import type { MetadataRoute } from 'next';
const PRIVATE = ['/api/', '/oauth/', '/*/cart', '/*/account'];
const AI_AGENTS = [
'GPTBot', 'OAI-SearchBot', 'ChatGPT-User',
'ClaudeBot', 'Claude-User', 'Claude-SearchBot',
'PerplexityBot', 'Perplexity-User',
'Google-Extended', 'Applebot-Extended',
];
export default function robots(): MetadataRoute.Robots {
return {
rules: [
{
userAgent: '*',
allow: '/',
disallow: PRIVATE,
// contentsignals.org: may be searched and used as live input to AI answers; training left unstated.
other: { 'Content-Signal': 'search=yes, ai-input=yes' },
},
{ userAgent: AI_AGENTS, allow: '/', disallow: PRIVATE },
],
sitemap: 'https://your-store.example/sitemap.xml',
};
}noindex. Let it crawl search results (their links lead to products), and mark them noindex, follow.search, ai-input (live answers) and ai-train. Leave out what you haven't decided.Search engines, answer engines and link previews read <title>, the meta description, the canonical and hreflang from the served HTML, and Google ignores a canonical or hreflang outside <head>.
<head>. A framework that streams it into <body> for ordinary browsers needs to put it in <head> for crawlers (in Next.js, htmlLimitedBots; see Next.js App Router).<title> comes before </head>.UA='Mozilla/5.0 AppleWebKit/537.36 (KHTML, like Gecko; compatible; Googlebot/2.1; +http://www.google.com/bot.html) Chrome/131.0.0.0 Safari/537.36'
curl -s -A "$UA" https://your-store.example/en/drills/cordless-drill-driver-18v | grep -bo '<title>\|</head>' | head -3UA='Mozilla/5.0 AppleWebKit/537.36 (KHTML, like Gecko; compatible; Googlebot/2.1; +http://www.google.com/bot.html) Chrome/131.0.0.0 Safari/537.36'
curl -s -A "$UA" https://your-store.example/en/drills/cordless-drill-driver-18v | grep -bo '<title>\|</head>' | head -3List every page in every language, each with hreflang alternates to the same item in the other languages. Paths are translated, so join them on itemId, not on the slug:
// app/sitemap.ts (one browse per language, joined on itemId)
const LANGS = ['en', 'no', 'sv'] as const;
const HREFLANG = { en: 'en', no: 'nb', sv: 'sv' } as const;
export default async function sitemap() {
const all = await Promise.all(LANGS.map(async (l) => [l, await allItems(l)] as const)); // itemId, path, updatedAt
const byItem = new Map<string, Partial<Record<(typeof LANGS)[number], string>>>();
for (const [l, items] of all) for (const i of items) byItem.set(i.itemId, { ...byItem.get(i.itemId), [l]: `https://your-store.example/${l}${i.path}` });
return all.flatMap(([l, items]) =>
items.map((i) => {
const urls = byItem.get(i.itemId)!;
return {
url: urls[l]!,
lastModified: i.updatedAt,
alternates: { languages: { ...Object.fromEntries(Object.entries(urls).map(([k, u]) => [HREFLANG[k as 'en'], u])), 'x-default': urls.en ?? urls[l]! } },
};
}),
);
}// app/sitemap.ts (one browse per language, joined on itemId)
const LANGS = ['en', 'no', 'sv'] as const;
const HREFLANG = { en: 'en', no: 'nb', sv: 'sv' } as const;
export default async function sitemap() {
const all = await Promise.all(LANGS.map(async (l) => [l, await allItems(l)] as const)); // itemId, path, updatedAt
const byItem = new Map<string, Partial<Record<(typeof LANGS)[number], string>>>();
for (const [l, items] of all) for (const i of items) byItem.set(i.itemId, { ...byItem.get(i.itemId), [l]: `https://your-store.example/${l}${i.path}` });
return all.flatMap(([l, items]) =>
items.map((i) => {
const urls = byItem.get(i.itemId)!;
return {
url: urls[l]!,
lastModified: i.updatedAt,
alternates: { languages: { ...Object.fromEntries(Object.entries(urls).map(([k, u]) => [HREFLANG[k as 'en'], u])), 'x-default': urls.en ?? urls[l]! } },
};
}),
);
}search count, because counts can vary during a re-index.allItems here is one Discovery browse per shape and language, paged with endToken, selecting itemId, path and updatedAt.
ChatGPT-User, Claude-User and Perplexity-User fetch a page when a person asks about it, rather than crawling ahead. They need the same things as a crawler: the content and metadata in the HTML, or a Markdown version (see llms.txt and Markdown).
robots.txt and sitemap.xml on the Tools Universe demo, with every page in four languages and hreflang alternates.