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.xmlThat’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
?inlineimport 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 insrc/lib/og/theme.tsas 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:widthandog: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.comto 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:
- Give your Person and Organisation stable
@ids, and use the same Person id on every site you own. - Build one graph per page with svead’s
SchemaOrg, typed with schema-dts. - Add a test that fails when a reference doesn’t resolve.
- Generate
llms.txtfrom the data your pages already use. - Check
robots.txtand your CDN rules actually let the search and user agents through. - Stop putting the build date in your sitemap.
- Make sure your
og:imageis 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.