Skip to content

robots, sitemap and llms.txt

The files a site describes itself with, from a file beside the root layout — written the way Next writes them, stored at build when they can be.

Updated View as Markdown

A crawler asks for /robots.txt and /sitemap.xml before it reads a page, and a model asks for /llms.txt. Each comes from a file beside the root layout, named for what it answers:

file answers returns
src/app/robots.ts /robots.txt MetadataRoute.Robots
src/app/sitemap.ts /sitemap.xml MetadataRoute.Sitemap
src/app/llms.ts /llms.txt MetadataRoute.Llms
src/app/llms-full.ts /llms-full.txt a string

The same names and shapes as Next, so a port copies them across unchanged.

src/app/robots.tsts
import type { MetadataRoute } from '@rsc-kit/core/metadata';

export default function robots(): MetadataRoute.Robots {
  return {
    rules: [
      { userAgent: '*', allow: '/', disallow: ['/api/', '/studio'] },
      { userAgent: 'GPTBot', disallow: '/' },
    ],
    sitemap: '/sitemap.xml',
  };
}
src/app/sitemap.tsts
import type { MetadataRoute } from '@rsc-kit/core/metadata';
import { db } from '@/lib/db';

export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
  const posts = await db.post.findMany({ select: { slug: true, updatedAt: true } });

  return [
    { url: '/', changeFrequency: 'weekly', priority: 1 },
    { url: '/pricing' },
    ...posts.map((post) => ({ url: `/blog/${post.slug}`, lastModified: post.updatedAt })),
  ];
}
src/app/llms.tsts
import type { MetadataRoute } from '@rsc-kit/core/metadata';

export default function llms(): MetadataRoute.Llms {
  return {
    title: 'Example',
    summary: 'What the site is, in one sentence.',
    sections: [
      { title: 'Pages', links: [{ title: 'Pricing', url: '/pricing', description: 'Per restoration, no subscription' }] },
    ],
  };
}

A relative url in any of them is made absolute with the root layout’s metadataBase; without one, a relative url is a build error that says so. Any of the three may return a string instead, served as written.

A sitemap you do not write

With no sitemap.ts, the build writes /sitemap.xml itself, from what it already knows: every page it stored and every url generateStaticParams listed, each with the build as lastModified. Left out: anything under a middleware.ts (a guard means not for everyone), a page the build could not render, and the not-found page. The build’s table says so:

○  /sitemap.xml
   written by the build: 5 urls; a sitemap.ts beside the root layout replaces it

It needs the root layout’s metadataBase for the host; without one the line says it was not written. A sitemap.ts replaces it entirely — the moment you want a page the build cannot see (a post per database row without generateStaticParams) or a changeFrequency, write one.

How fresh

Three choices, and the function decides — the same rule every route follows:

you write served fresh
nothing the build’s own sitemap, stored every deploy
a sitemap.ts that reads the database stored at build every deploy
a sitemap.ts that awaits connection() rendered per request every crawl
src/app/sitemap.tsts
import { connection } from '@rsc-kit/core/request';

export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
  await connection(); // per request — the same mark a page uses

  const posts = await db.post.findMany({ select: { slug: true, updatedAt: true } });

  return posts.map((post) => ({ url: `/blog/${post.slug}`, lastModified: post.updatedAt }));
}

Without the connection() line the same function runs once at build and the answer is stored; the build’s table shows ○ for a stored one and ƒ for one that runs per request. Reading the database at build is fine — it is the request that makes a route dynamic, not the data.

How they are served

Each file becomes an api route, and that decides the rest. A sitemap.ts that reads nothing per request — the database counts as nothing, the request does not — is answered once at build and stored, like any frozen route, and served from the file after. One that reads cookies() or awaits connection() stays dynamic and runs per request. The build’s table lists them with the other routes and says which.

They run no middleware, on purpose: a guard on the root layout’s directory would otherwise answer a crawler’s request for robots.txt with a 401.

The urls are typed like every other route, so route('/sitemap.xml') is a link the build checks.

Files as written

A hand-written file beside the root layout is served at the root as it is: robots.txt, sitemap.xml (or sitemap-posts.xml), llms.txt, llms-full.txt, and the others a site is asked for there — humans.txt, security.txt, ads.txt. In development they are read from app/; a build copies them beside the client output. A file and a function for the same url is a build error naming both.

Coming from Next

app/robots.ts and app/sitemap.ts carry across unchanged. app/llms.txt in Next is usually a route.ts; here it is llms.ts with a shape, or a file as written. Next’s generateSitemaps() for a sitemap split across files is not here: a sitemap-posts.xml written by hand, or a route.ts under app/sitemap/, covers it.

Navigation

Type to search…

↑↓ navigate↵ selectEsc close