Search the site
Find pages, services and blog posts
Skip to content
All posts

Making a SvelteKit Site Findable by AI Agents and Search

  • sveltekit
  • svelte
  • seo
  • ai
  • json-ld

More of the people who might hire me aren’t typing “Svelte consultant UK” into a search box any more. They ask ChatGPT, Claude or Perplexity, and an agent goes off, reads a handful of pages and comes back with a shortlist. If my site is hard for that agent to read, or says something different from my other sites, I’m not on the list.

This week I reworked how svelteconsulting.dev describes itself to machines. None of it is exotic. It’s structured data that is actually linked up, a plain text summary built from the same data as the pages, a robots.txt that says yes, sitemap dates that don’t lie and link preview cards that platforms will render. This post walks through each piece, how it’s built in SvelteKit, and how I’ll know whether any of it made a difference.

There is no secret AEO trick

I did the same exercise for scottspence.com last month, and wrote myself a plan before touching any code. The conclusion of that plan set the tone for this one: there’s no shared “answer engine ranking”, no guaranteed inclusion and no single score that means anything across ChatGPT, Claude, Perplexity, Gemini and Google’s AI features.

What you can do is make your pages crawlable, make your identity consistent everywhere it appears, publish things that answer real questions with first-hand evidence, and then measure. Google says outright that structured data isn’t required for its generative AI features. I use it for clarity and eligibility, not as a shortcut.

So the goal here was narrow. When an agent lands on any page of this site, it should be able to tell who I am, what the practice sells, how the pages relate to each other, and where to find the rest.

One graph per page, linked by @id

The site already had JSON-LD, but it was the usual mess of separate blocks per page, each one repeating a slightly different version of me. The fix was to build one @graph per page where every node has a stable @id and refers to the others by that id rather than copying them.

The shared nodes live in src/lib/seo/schema.ts: a ProfessionalService for the practice, a Person for me and a WebSite. They’re typed with schema-dts, so a typo in a property name is a type error instead of something I find out about in the Rich Results Test.

1export const author_website = 'https://scottspence.com';
2
3export const person_id = `${author_website}/#person`;
4export const organization_id = `${website}/#organization`;
5export const website_id = `${website}/#website`;
6
7const ref = (id: string): IdReference => ({ '@id': id });

The interesting line is person_id. My Person node’s @id is on scottspence.com, not on this site. oestechnology.co.uk uses the same id. Three sites, one person. When a crawler or an agent pieces together who “Scott Spence” is, it gets the same identifier, the same job title and the same sameAs profiles from every one of them, instead of three near-duplicates it has to reconcile.

The organisation points back at me with founder and employee references, and lists every service from services.ts in an OfferCatalog. Each service gets its own @id too, so the service page can say “this page is about that service” without restating it.

1export const organization_schema: Node<ProfessionalService> = {
2	'@type': 'ProfessionalService',
3	'@id': organization_id,
4	name,
5	url: website,
6	founder: ref(person_id),
7	employee: ref(person_id),
8	hasOfferCatalog: {
9		'@type': 'OfferCatalog',
10		name: 'Svelte and SvelteKit services',
11		itemListElement: services.map((service) => ({
12			'@type': 'Offer',
13			itemOffered: {
14				'@type': 'Service',
15				'@id': service_id(service.slug),
16				name: service.title,
17				url: absolute_url(`/services/${service.slug}`),
18			},
19		})),
20	},
21};

Rendering it with svead

svead is my own SvelteKit SEO library, and its SchemaOrg component takes either a single node or an array. Pass an array and it emits one <script type="application/ld+json"> with a single @graph, stripping any per-node @context along the way.

The site’s seo.svelte component builds the graph from the three shared nodes, a WebPage for the current route, breadcrumbs when the page isn’t the home page, and whatever extra nodes the page passes in:

1<script lang="ts">
2	const graph = $derived([
3		...base_graph,
4		web_page_schema({
5			type: page_type,
6			path,
7			title: full_title,
8			description,
9			breadcrumb: crumbs.length > 0,
10			main_entity,
11		}),
12		...(crumbs.length ? [breadcrumb_schema(path, crumbs)] : []),
13		...schema,
14	] satisfies SchemaOrgProps['schema']);
15</script>
16
17<Head {seo_config} />
18<SchemaOrg schema={graph} />

A blog post then only has to add its BlogPosting node and say that’s the page’s main entity. The post’s author carries the shared Person @id, mainEntityOfPage points at the post’s own canonical URL (scottspence.com had this pointing at the home page for years, so I was careful here), and the dates go out as full ISO strings with a time zone, which is what Google asks for.

Because everything is typed with schema-dts and svead’s props accept those types, there are no casts anywhere in the chain. That was the main change in the second commit, and it’s the bit I’d recommend most if you’re doing this yourself.

Testing that the graph actually resolves

Linked data has a failure mode that nothing warns you about. Reference an @id that no node on the page defines and the markup is still valid JSON, the page still renders, and the relationship quietly points at nothing.

So there are two small helpers in src/lib/testing/json-ld.ts. unresolved_refs walks the graph and returns any { "@id": ... } reference with no node defining that id. duplicate_ids catches the same @id appearing twice at the top level.

1export function unresolved_refs(graph: unknown[]): string[] {
2	const defined = new Set<string>();
3	const referenced = new Set<string>();
4	walk(graph, (node) => {
5		const id = node['@id'];
6		if (typeof id !== 'string') return;
7		(Object.keys(node).length > 1 ? defined : referenced).add(id);
8	});
9	return [...referenced].filter((id) => !defined.has(id));
10}

The Playwright suite in src/routes/seo.e2e.ts visits every static route and every post, and for each one checks there’s exactly one title, description, canonical and JSON-LD block, that the graph contains the organisation, person and website nodes, that every reference resolves, that nested pages have breadcrumbs ending at the canonical URL, and that posts have a BlogPosting with an author, dates and an absolute image URL. Add a page and forget the Seo component, and the build tells me.

llms.txt from the same data as the pages

llms.txt is a proposed convention: a Markdown file at the root of a site that gives a language model a short summary and links to the pages worth reading. Nobody has promised to read it, and I’m not claiming any agent does. It costs very little to serve, though, and it’s exactly what an agent with a small context window would want.

The part I cared about was not writing it by hand. A hand-written llms.txt goes stale the first time a service description changes. Mine is built in src/lib/seo/llms.ts from services.ts, the post list and the site config, which are the same sources the pages render from. When I edit a service summary, the service page, its meta description and llms.txt all change together.

The route itself is tiny and prerendered:

1export const prerender = true;
2
3export const GET: RequestHandler = () =>
4	new Response(llms_txt({ services, posts: get_posts() }), {
5		headers: { 'Content-Type': 'text/plain; charset=utf-8' },
6	});

There’s also /llms-full.txt, which has everything in the summary plus each service in full, the about page and every post, with frontmatter and Svelte component markup stripped out. An agent that wants the whole picture in one request can have it.

Writing the service summaries for this had a side effect I didn’t expect. Each service in services.ts now has a short summary of about 150 characters, written in the first person, that says what I actually do. That’s now the meta description on the service pages too, and it’s better than what was there before.

A robots.txt that says yes

Plenty of sites block AI crawlers by default now, and that’s a reasonable choice for a lot of publishers. For a consulting site whose whole job is to be found, it’s the wrong one.

1# svelteconsulting.dev wants to be found: search engines and AI
2# crawlers are all welcome.
3# Summary for language models: https://svelteconsulting.dev/llms.txt
4
5User-agent: *
6Allow: /
7
8User-agent: GPTBot
9User-agent: OAI-SearchBot
10User-agent: ChatGPT-User
11User-agent: ClaudeBot
12User-agent: Claude-SearchBot
13User-agent: Claude-User
14User-agent: PerplexityBot
15User-agent: Perplexity-User
16Allow: /
17
18Sitemap: https://svelteconsulting.dev/sitemap.xml

That’s trimmed; the real file lists the search engines and a few more AI agents as well. Strictly, the wildcard Allow: / already covers all of them. Naming them documents intent, and it matters because vendors split their crawlers by purpose. OpenAI’s GPTBot is about training, OAI-SearchBot is what ChatGPT Search uses to find pages, and ChatGPT-User is an agent fetching a page because someone asked it to. Anthropic and Perplexity have a similar split. Block the wrong one and you vanish from the answers while thinking you only opted out of training.

robots.txt is also only the first gate. On scottspence.com I found PerplexityBot getting 403s from the edge despite being allowed in the config, so checking your CDN or firewall logs matters as much as the file.

Sitemap dates that mean something

The old sitemap stamped every URL with the build date as its lastmod. That tells a crawler every page changed on every deploy, which is false, and a signal that is always on stops being a signal.

Now posts get their real updated or date from frontmatter, the blog index gets the newest post’s date, and pages with no known change date leave lastmod out entirely:

1/** Pages without a known change date leave lastmod out rather than claim today */
2function to_url_entry(entry: SitemapEntry): string {
3	const lastmod = entry.lastmod
4		? `\n    <lastmod>${entry.lastmod}</lastmod>`
5		: '';
6	return `  <url>
7    <loc>${website}${entry.path}</loc>${lastmod}
8    <priority>${entry.priority}</priority>
9  </url>`;
10}

Leaving a field out is more honest than making one up.

PNG link cards

When an agent or a person shares a link, the preview card is often the first thing anyone sees. The site’s og:image pointed at a PNG that had gone missing, so every share went out with no image.

The replacement is a route at /api/og-image/[slug].png that builds an SVG card and renders it to PNG with resvg-js. It has to be a PNG: LinkedIn, X and Facebook ignore SVG. Posts get their title and date; every other page gets a site card with my name.

A few details that took longer than they should have:

  • The title is wrapped and sized by measuring each candidate line in resvg itself, with the actual font, so long titles shrink to fit the box instead of running off the edge.
  • The font is bundled with Vite’s ?inline import so it ships in the server build. resvg-js ignored the font buffer option in my testing, so the server writes the font to a temp file once and hands resvg the path.
  • resvg can’t read CSS custom properties or oklch(), so the card colours live in src/lib/og/theme.ts as hex copies of the site’s tokens. When the design changes, that’s the one file to touch.
  • Every page now declares og:image:type, og:image:width and og:image:height, and the e2e suite checks the PNG really is 1200x630 by reading the width and height from its header bytes.

What I’ll measure

All of this shipped this week, so I have no results to show you, and I’m not going to pretend otherwise. Here’s what I’ll be watching:

  • A fixed set of questions, asked of ChatGPT, Claude, Perplexity and Google each month with the wording kept the same. Things like “Svelte consultant UK” and “SvelteKit migration consultant”. I’ll record whether the answer mentions me, whether it cites a page on this site, which page, and whether what it says is accurate.
  • Bing Webmaster Tools AI Performance, which reports how often Copilot cites your pages, and Google Search Console, for indexing and query data.
  • Referral traffic from AI search. ChatGPT Search adds utm_source=chatgpt.com to the links it sends, so those visits are easy to pick out.
  • Crawler logs, to confirm the search and user agents are getting 200s, not just being allowed on paper.
  • Contact form enquiries that mention finding me through an AI tool, which is the number that pays the bills.

A single run of any of these is noise. Answers vary between runs and between days, so I’ll look at the trend over 30 and 90 days rather than any one result.

If you want to do the same

None of this needs a rewrite of an existing SvelteKit site. The order I’d do it in:

  1. Give your Person and Organisation stable @ids, and use the same Person id on every site you own.
  2. Build one graph per page with svead’s SchemaOrg, typed with schema-dts.
  3. Add a test that fails when a reference doesn’t resolve.
  4. Generate llms.txt from the data your pages already use.
  5. Check robots.txt and your CDN rules actually let the search and user agents through.
  6. Stop putting the build date in your sitemap.
  7. Make sure your og:image is a PNG that exists.

None of it guarantees an agent will recommend you. It does mean that when one reads your site, it gets a clear, consistent answer about who you are and what you do. If you’d like a hand doing this on your own SvelteKit site, get in touch.