Spec 001Technical SEO implementation
Audited from 2,100 production URLs
The SEO playbookfor Solo SaaS founders
SEO for Solos is the complete technical SEO playbook for a company of one: 112 checks, 43 schema types, and the TypeScript that implements them.
- One-time payment
- Lifetime updates
- 30 day refund, no questions
/checklist · sorted by return on effort
112 checks
- Every page has a self-referencing absolute canonicalmeta-canonical-absolute · impact 5/5 · effort SFree
- Apply the strip-the-entity-name test before publishing a tierpseo-strip-the-name-test · impact 5/5 · effort SFree
- Ship the foundation group before anything elseseq-foundation-first · impact 5/5 · effort SFree
- One canonical host, with a permanent redirect from the otherfound-canonical-host · impact 4/5 · effort SFree
00Provenance
Where the numbers come from
SEO for Solos was produced by auditing a production codebase of roughly 2,100 indexable URLs using 42 schema.org types, with a 1,689 page programmatic tier. These are counts from that audit, not estimates.
This site also scores itself against its own checklist and publishes the result, including the checks it fails. See the current score.
01The problem
You are the engineer, the marketer and the support desk. SEO gets whatever hours are left over, which is why it never gets finished.
The posts you have read are written for teams with an SEO hire. The mechanisms that matter go unwritten: bot economics, llms.txt, differentiated lastModified, entity gating.
“Add BreadcrumbList schema” is a sentence. A tested factory that composes into a sitewide graph with stable identifiers is a day of work a solo founder does not have.
02The artefacts
What you get
01
The playbook
20 chapters, 4 of them free in full. Every subsection is a principle, an implementation and a worked example with real numbers.
Read a free chapter→02
The checklist
112 checks across 12 groups, each with a command you can run. Free, no account, and genuinely useful empty.
Open it→03
The Code Kit
12 modules of tested TypeScript, from the schema graph composer to the programmatic page template. Every module has a test that fails if it regresses.
See the modules→04
The PDF
The same content, print-formatted, watermarked with your licence at download time. Generated from the same source as the reader, so the two cannot diverge.
See pricing→03AI search
AI search, covered properly
The part competitors do not haveAnswer engines synthesise an answer and cite sources. Extraction replaces ranking, which means being unquotable is worth nothing regardless of where you rank. Three chapters cover it, and this site implements every one of them on itself.
The bot economics split
Not AI versus search. Whether a crawler can return a visit or a citation for the bandwidth it consumes. OpenAI, Google, Anthropic and Apple all use separate agents for training and for search, which is what makes a selective policy possible.
- AllowOAI-SearchBot, PerplexityBot, ClaudeBot
- DenyGPTBot, Google-Extended, CCBot
llms.txt, written properly
Entity definition, key facts, explicit negative statements, a canonical answer per likely question, and citation guidance telling an assistant how to qualify a price that changes. Nobody else is writing the last part down.
04The Code Kit
What is inside the Code Kit
Framework-agnostic where feasible, Next.js first, fully typed, every module tested. This site consumes it for its own SEO: if the kit were not good enough for us, it would not be good enough to sell.
| Module | Implements |
|---|---|
seo/constants.ts | Canonical normalisation, Open Graph, Twitter cards |
seo/schema/ | 43 factories plus the @graph composer with @id anchors |
seo/robots.ts | Bot-economics policy generator, search versus training split |
seo/sitemap.ts | Route-group composer, differentiated lastModified, integrity report |
seo/indexnow.ts | Batch submission, timing-safe cron guard |
seo/feeds.ts | RSS from the sitemap, XML escaping, CDATA safety |
seo/llms.ts | llms.txt and llms-full.txt builders from typed config |
pseo/gating.ts | Entity gating against an authoritative list |
pseo/facts.ts | Per-entity figures, computed FAQs, honest fallbacks, sibling selection |
pseo/generate.ts | Snapshot generation with a shrink guard |
config/headers.ts | HSTS, nosniff, frame-deny, referrer, permissions, a real CSP |
tests/ | Vitest suites for every module above |
The schema graph composer, in full (136 lines)
This is the file, not an excerpt. It is the difference between 43 disconnected script tags and one entity asserted from every page.
/**
* Code Kit: the sitewide @graph composer.
* Implements checks: schema-graph-anchors, schema-single-script, schema-no-duplicates
*
* One <script type="application/ld+json"> per page holding one @graph array.
* Nodes cross-reference each other by @id instead of duplicating themselves,
* which is what stops a site from shipping the same Organization object 2,000
* times and lets a crawler resolve one entity across every page.
*/
import { compact } from "../constants";
import { organization, website, type OrganizationOptions, type WebSiteOptions } from "./organization";
import { product, type ProductOptions } from "./commerce";
import { type JsonLdObject, SCHEMA_CONTEXT, nodeId, ref } from "./types";
export type SiteGraphOptions = {
siteUrl: string;
organization: Omit<OrganizationOptions, "siteUrl">;
website: Omit<WebSiteOptions, "siteUrl" | "publisher">;
/** Omit on sites that do not sell a product. */
product?: Omit<ProductOptions, "siteUrl">;
/** Page-level nodes: WebPage, BreadcrumbList, Article, FAQPage, and so on. */
additionalNodes?: Array<JsonLdObject | undefined>;
};
/**
* Compose the sitewide graph.
*
* The three anchors are stable and cross-referenced:
* #organization is the publisher of #website and the brand of #product
* #website is the isPartOf target for every page node
* #product is the thing being sold
*/
export function siteGraph(opts: SiteGraphOptions): JsonLdObject {
const orgId = nodeId(opts.siteUrl, "#organization");
const nodes: Array<JsonLdObject | undefined> = [
organization({ siteUrl: opts.siteUrl, ...opts.organization }),
website({ siteUrl: opts.siteUrl, publisher: ref(orgId), ...opts.website }),
opts.product
? product({
siteUrl: opts.siteUrl,
...opts.product,
brandName: opts.product.brandName,
})
: undefined,
...(opts.additionalNodes ?? []),
];
return {
"@context": SCHEMA_CONTEXT,
"@graph": dedupeById(nodes.filter(isPresent)) as unknown as JsonLdObject[],
} as unknown as JsonLdObject;
}
/**
* Build a page-level graph that references the sitewide anchors without
* repeating them. Use on every page that is not the home page.
*/
export function pageGraph(opts: {
siteUrl: string;
nodes: Array<JsonLdObject | undefined>;
}): JsonLdObject {
return {
"@context": SCHEMA_CONTEXT,
"@graph": dedupeById(opts.nodes.filter(isPresent)) as unknown as JsonLdObject[],
} as unknown as JsonLdObject;
}
function isPresent<T>(value: T | undefined | null): value is T {
return value !== undefined && value !== null;
}
/** Two nodes with the same @id in one graph is a validation error. Last write wins. */
function dedupeById(nodes: JsonLdObject[]): JsonLdObject[] {
const seen = new Map<string, JsonLdObject>();
const anonymous: JsonLdObject[] = [];
for (const node of nodes) {
const id = typeof node["@id"] === "string" ? node["@id"] : undefined;
if (id) seen.set(id, node);
else anonymous.push(node);
}
return [...seen.values(), ...anonymous];
}
/**
* Collect every @id declared in a graph and every @id referenced by it, so a
* test can assert that no reference dangles.
*
* The distinction that matters: an object carrying `@id` plus other properties
* DECLARES a node, at any nesting depth. An object carrying nothing but `@id`
* REFERENCES one. A nested ImageObject with its own `@id` is a declaration, and
* treating it as a dangling reference would be a false positive.
*/
export function graphIdIntegrity(graph: JsonLdObject): {
declared: Set<string>;
referenced: Set<string>;
dangling: string[];
} {
const declared = new Set<string>();
const referenced = new Set<string>();
const nodes = (graph["@graph"] as unknown as JsonLdObject[]) ?? [];
const walk = (value: unknown): void => {
if (Array.isArray(value)) {
for (const item of value) walk(item);
return;
}
if (!value || typeof value !== "object") return;
const obj = value as Record<string, unknown>;
const keys = Object.keys(obj);
const id = typeof obj["@id"] === "string" ? obj["@id"] : undefined;
if (id) {
if (keys.length === 1) referenced.add(id);
else declared.add(id);
}
for (const [key, val] of Object.entries(obj)) {
if (key === "@id") continue;
walk(val);
}
};
for (const node of nodes) walk(node);
return {
declared,
referenced,
dangling: [...referenced].filter((id) => !declared.has(id)),
};
}
export { compact };The bot economics policy generator (293 lines)
The crawler registry, the class split and the generator, with the reasoning as comments because people read robots.txt when evaluating a site.
/**
* Code Kit: bot-economics robots policy.
* Implements checks: crawl-robots-generated, crawl-bot-economics,
* crawl-private-routes-disallowed, crawl-sitemap-declared
*
* The decision nobody makes deliberately: which crawlers get your content.
* The useful split is not "AI bots" versus "search bots". It is whether a
* crawler can send you traffic or a citation in return for the bandwidth.
*
* Search crawlers index you and send clicks. Allow.
* Citation crawlers fetch a page to answer a live query
* and name the source. Allow.
* Training crawlers build a model. No link, no click,
* no attribution. Your call.
*
* Blocking a training crawler does not remove you from that company's answer
* engine if its citation crawler is a separate user agent, which for OpenAI,
* Anthropic, Perplexity and Google it currently is. That asymmetry is the whole
* mechanism.
*/
export type CrawlerClass = "search" | "citation" | "training" | "seo-tool" | "archive";
export type CrawlerProfile = {
userAgent: string;
operator: string;
class: CrawlerClass;
/** Does allowing this crawler produce visits or citations? */
sendsReferralTraffic: boolean;
purpose: string;
documentationUrl?: string;
};
/**
* Crawler registry.
*
* Verified against each operator's published documentation. Recheck quarterly:
* user agent names change, and new ones appear faster than anyone updates
* robots.txt.
*/
export const CRAWLERS: CrawlerProfile[] = [
{
userAgent: "Googlebot",
operator: "Google",
class: "search",
sendsReferralTraffic: true,
purpose: "Google Search index. Also feeds AI Overviews.",
documentationUrl: "https://developers.google.com/search/docs/crawling-indexing/overview-google-crawlers",
},
{
userAgent: "Bingbot",
operator: "Microsoft",
class: "search",
sendsReferralTraffic: true,
purpose: "Bing index. Also the retrieval layer behind Copilot.",
documentationUrl: "https://www.bing.com/webmasters/help/which-crawlers-does-bing-use-8c184ec0",
},
{
userAgent: "DuckDuckBot",
operator: "DuckDuckGo",
class: "search",
sendsReferralTraffic: true,
purpose: "DuckDuckGo index.",
},
{
userAgent: "Applebot",
operator: "Apple",
class: "search",
sendsReferralTraffic: true,
purpose: "Siri and Spotlight suggestions.",
documentationUrl: "https://support.apple.com/en-us/119829",
},
{
userAgent: "OAI-SearchBot",
operator: "OpenAI",
class: "citation",
sendsReferralTraffic: true,
purpose: "ChatGPT search index. Links back to the source.",
documentationUrl: "https://platform.openai.com/docs/bots",
},
{
userAgent: "ChatGPT-User",
operator: "OpenAI",
class: "citation",
sendsReferralTraffic: true,
purpose: "Live fetch when a user asks ChatGPT about a specific URL.",
documentationUrl: "https://platform.openai.com/docs/bots",
},
{
userAgent: "PerplexityBot",
operator: "Perplexity",
class: "citation",
sendsReferralTraffic: true,
purpose: "Perplexity answer index. Cites sources inline.",
documentationUrl: "https://docs.perplexity.ai/guides/bots",
},
{
userAgent: "ClaudeBot",
operator: "Anthropic",
class: "citation",
sendsReferralTraffic: true,
purpose: "Claude retrieval and citation.",
documentationUrl: "https://support.anthropic.com/en/articles/8896518",
},
{
userAgent: "Claude-User",
operator: "Anthropic",
class: "citation",
sendsReferralTraffic: true,
purpose: "Live fetch when a Claude user asks about a specific URL.",
},
{
userAgent: "Applebot-Extended",
operator: "Apple",
class: "training",
sendsReferralTraffic: false,
purpose: "Apple foundation model training. Opting out does not affect Siri.",
documentationUrl: "https://support.apple.com/en-us/119829",
},
{
userAgent: "GPTBot",
operator: "OpenAI",
class: "training",
sendsReferralTraffic: false,
purpose: "Model training corpus. Separate from OAI-SearchBot.",
documentationUrl: "https://platform.openai.com/docs/bots",
},
{
userAgent: "Google-Extended",
operator: "Google",
class: "training",
sendsReferralTraffic: false,
purpose: "Gemini training. Blocking it does not remove you from Search.",
documentationUrl: "https://developers.google.com/search/docs/crawling-indexing/overview-google-crawlers",
},
{
userAgent: "anthropic-ai",
operator: "Anthropic",
class: "training",
sendsReferralTraffic: false,
purpose: "Legacy training crawler identifier.",
},
{
userAgent: "CCBot",
operator: "Common Crawl",
class: "training",
sendsReferralTraffic: false,
purpose: "Open crawl corpus used as training data by many labs.",
documentationUrl: "https://commoncrawl.org/ccbot",
},
{
userAgent: "Bytespider",
operator: "ByteDance",
class: "training",
sendsReferralTraffic: false,
purpose: "Training corpus. Widely reported to ignore robots.txt.",
},
{
userAgent: "AhrefsBot",
operator: "Ahrefs",
class: "seo-tool",
sendsReferralTraffic: false,
purpose: "Backlink index. Allowing it makes your own backlink data visible to you and to competitors.",
},
{
userAgent: "SemrushBot",
operator: "Semrush",
class: "seo-tool",
sendsReferralTraffic: false,
purpose: "Competitive index. Same trade as AhrefsBot.",
},
{
userAgent: "ia_archiver",
operator: "Internet Archive",
class: "archive",
sendsReferralTraffic: false,
purpose: "Wayback Machine preservation.",
},
];
export type RobotsPolicy = {
/** Crawler classes permitted to crawl. */
allowClasses: CrawlerClass[];
/** Paths every permitted crawler is denied. */
disallowPaths: string[];
/** Absolute sitemap URLs. */
sitemaps: string[];
/** Canonical host, no scheme. Bing reads this; Google ignores it. */
host?: string;
/** Extra user agents to deny outright, beyond the class rules. */
denyUserAgents?: string[];
/** Emit the reasoning as comments. Leave on: people read this file. */
annotate?: boolean;
crawlers?: CrawlerProfile[];
};
export const RECOMMENDED_POLICY: Pick<RobotsPolicy, "allowClasses"> = {
// Allow anything that can send a visit or a citation. Deny training-only
// crawlers and commercial SEO indexes, which take bandwidth and return nothing.
allowClasses: ["search", "citation"],
};
export type RobotsRule = {
userAgent: string | string[];
allow?: string | string[];
disallow?: string | string[];
};
/** Structured output, for `app/robots.ts` in Next.js. */
export function robotsRules(policy: RobotsPolicy): RobotsRule[] {
const crawlers = policy.crawlers ?? CRAWLERS;
const allowed = crawlers.filter((c) => policy.allowClasses.includes(c.class));
const denied = crawlers.filter((c) => !policy.allowClasses.includes(c.class));
const denyList = [
...denied.map((c) => c.userAgent),
...(policy.denyUserAgents ?? []),
];
const rules: RobotsRule[] = [
// The wildcard rule governs every crawler not named below, including ones
// that do not exist yet.
{ userAgent: "*", allow: "/", disallow: policy.disallowPaths },
];
for (const crawler of allowed) {
rules.push({ userAgent: crawler.userAgent, allow: "/", disallow: policy.disallowPaths });
}
for (const userAgent of denyList) {
rules.push({ userAgent, disallow: "/" });
}
return rules;
}
/** Plain text output, for a static robots.txt or the generator tool. */
export function robotsTxt(policy: RobotsPolicy): string {
const crawlers = policy.crawlers ?? CRAWLERS;
const annotate = policy.annotate ?? true;
const lines: string[] = [];
if (annotate) {
lines.push(
"# robots.txt",
"#",
"# Policy: allow any crawler that can return a visit or a citation.",
"# Deny crawlers that consume bandwidth and return neither.",
"#",
);
}
const emit = (rule: RobotsRule, comment?: string) => {
if (annotate && comment) lines.push(`# ${comment}`);
const agents = Array.isArray(rule.userAgent) ? rule.userAgent : [rule.userAgent];
for (const agent of agents) lines.push(`User-agent: ${agent}`);
for (const allow of toArray(rule.allow)) lines.push(`Allow: ${allow}`);
for (const disallow of toArray(rule.disallow)) lines.push(`Disallow: ${disallow}`);
lines.push("");
};
emit(
{ userAgent: "*", allow: "/", disallow: policy.disallowPaths },
"Default policy for every crawler not named below.",
);
for (const crawler of crawlers.filter((c) => policy.allowClasses.includes(c.class))) {
emit(
{ userAgent: crawler.userAgent, allow: "/", disallow: policy.disallowPaths },
`${crawler.operator}: ${crawler.purpose}`,
);
}
const denied = crawlers.filter((c) => !policy.allowClasses.includes(c.class));
for (const crawler of denied) {
emit(
{ userAgent: crawler.userAgent, disallow: "/" },
`${crawler.operator}: ${crawler.purpose} No referral traffic.`,
);
}
for (const userAgent of policy.denyUserAgents ?? []) {
emit({ userAgent, disallow: "/" });
}
for (const sitemap of policy.sitemaps) lines.push(`Sitemap: ${sitemap}`);
if (policy.host) lines.push(`Host: ${policy.host}`);
return `${lines.join("\n").replace(/\n{3,}/g, "\n\n").trimEnd()}\n`;
}
function toArray(value: string | string[] | undefined): string[] {
if (!value) return [];
return Array.isArray(value) ? value : [value];
}05The writing
Read the actual writing
Free chapter, in fullThis is the bot economics chapter, complete. Judge the quality before deciding anything.
Bot economics
The wrong question and the right one
The principle
The question people ask is "should I block AI bots?". It is the wrong question, because it groups together crawlers that do completely different things and returns completely different value.
The right question is per crawler: can this crawler return a visit or a citation in exchange for the bandwidth it consumes?
That splits the population three ways:
- Search crawlers index you and send clicks. Allow.
- Citation crawlers fetch a page to answer a live query and name the source. Allow.
- Training crawlers build a model. No link, no click, no attribution. Your call, and it is a genuine business decision rather than a technical one.
Why the split works
Because the major operators use separate user agents for the two jobs. Blocking OpenAI's training crawler does not remove you from ChatGPT's search index, because that index is fetched by a different agent. The same holds for Google and for Anthropic. That asymmetry is the entire mechanism, and it means you can take the citations without donating the training corpus.
The crawlers, one by one
Search crawlers: allow
| User agent | Operator | What it does |
|---|---|---|
Googlebot | Search index. Also feeds AI Overviews. | |
Bingbot | Microsoft | Bing index, and the retrieval layer behind Copilot. |
DuckDuckBot | DuckDuckGo | DuckDuckGo index. |
Applebot | Apple | Siri and Spotlight suggestions. |
Blocking any of these is a decision to not exist in that surface. There is no upside unless you are under crawl-rate pressure, and the fix for that is a crawl-delay conversation with the operator, not a block.
Citation crawlers: allow
| User agent | Operator | What it does |
|---|---|---|
OAI-SearchBot | OpenAI | ChatGPT search index. Links back to the source. |
ChatGPT-User | OpenAI | Live fetch when a user asks about a specific URL. |
PerplexityBot | Perplexity | Answer index. Cites sources inline. |
ClaudeBot | Anthropic | Retrieval and citation. |
Claude-User | Anthropic | Live fetch for a specific URL. |
These are the crawlers that make answer engine visibility possible. Blocking them is the AEO equivalent of blocking Googlebot, and a surprising number of sites do it by accident by using a blocklist copied from a blog post that predates the split.
Training crawlers: your decision
| User agent | Operator | What it does |
|---|---|---|
GPTBot | OpenAI | Training corpus. Separate from OAI-SearchBot. |
Google-Extended | Gemini training. Blocking it does not affect Search. | |
Applebot-Extended | Apple | Apple model training. Blocking it does not affect Siri. |
anthropic-ai | Anthropic | Legacy training identifier. |
CCBot | Common Crawl | Open corpus used as training data by many labs. |
Bytespider | ByteDance | Training corpus. Widely reported to ignore robots.txt entirely. |
The argument for allowing them: models trained on your content may describe your product more accurately, and the effect on brand presence in a model's parametric knowledge is real if unmeasurable.
The argument against: it is a pure cost. Bandwidth out, nothing back, no attribution, no link.
There is no correct answer here, only a decision. The recommended default in this playbook is to deny, because a cost with no measurable return is hard to defend, and because the citation crawlers remain allowed so answer engine visibility is unaffected.
Commercial SEO crawlers: usually deny
AhrefsBot and SemrushBot index your backlinks. Allowing them makes your backlink profile visible to you through their tools, and equally visible to your competitors. It is a genuine trade rather than an obvious call. If you do not pay for either tool, you are paying bandwidth for a service you cannot use.
The implementation
export const RECOMMENDED_POLICY = {
allowClasses: ["search", "citation"],
};
export function robotsTxt(policy: RobotsPolicy): string {
// one block per allowed crawler with the full disallow list repeated,
// one Disallow: / block per denied crawler,
// then Sitemap and Host
}Annotate the generated file. People read robots.txt as part of evaluating a site, and a file that explains its own reasoning is a small credibility signal that costs nothing:
# Default policy for every crawler not named below.
User-agent: *
Allow: /
Disallow: /api/
# OpenAI: ChatGPT search index. Links back to the source.
User-agent: OAI-SearchBot
Allow: /
Disallow: /api/
# OpenAI: Model training corpus. Separate from OAI-SearchBot. No referral traffic.
User-agent: GPTBot
Disallow: /Score your site before spending anything
All 112 checks are free, with 26 implementation snippets unlocked. No account required.
06Pricing
Three tiers, all one-time payments
Playbook + Code Kit
Recommended$149
Everything above, plus the tested TypeScript that implements it.
Get the playbook and code→$149is roughly two hours of a senior engineer's billing rate, paid once, against the monthly retainer an agency would quote a company of one. The schema graph composer alone is a day. Compare every tier.
07Fit
Who this is not for
- If you cannot write code. Most of the value here is TypeScript you adopt into a repository. There is nothing to install.
- If you have an SEO team already. Everything here is scoped to a company of one. A team that owns SEO full-time will know most of the checklist, though the Code Kit may still save it a sprint.
- If you want rankings guaranteed. Nobody can offer that. This promises implementation completeness and says so in the terms.
- If you want it done for you. Nothing here is a service. This is a playbook and code you adopt and ship yourself.
- If you already score above 90 on the checklist. Go and find out first. It is free, and discovering you do not need this is a better outcome for both of us than a refund.
08Questions
Questions
What exactly is SEO for Solos?
SEO for Solos is a 20-chapter technical SEO playbook for solo SaaS founders, covering 112 implementation checks, 42 schema.org types and a working TypeScript code kit, taken from a production codebase of roughly 2,100 indexable URLs rather than from opinion. It exists so a one-person company can implement its own SEO once, without an agency retainer.
Who is SEO for Solos for?
SEO for Solos is written for solo SaaS founders and one-person product teams who do their own engineering. Every check is scoped to what one person can implement and verify alone, which is also why it sells code rather than a service: there is no agency retainer a company of one can sensibly carry.
Is this a subscription?
SEO for Solos is not a subscription. Every tier is a one-time payment that includes lifetime updates to the version purchased.
What framework does it assume?
SEO for Solos is Next.js App Router first, because that is what the audited codebase runs. Every chapter carries framework notes, and 8 frameworks have their own page with real per-framework code.
What if I already know most of this?
Score yourself on the free checklist before buying. SEO for Solos publishes all 112 checks with their verification commands at no cost, so you can find out what you are missing without paying for the answer.
Does it guarantee rankings?
SEO for Solos does not guarantee rankings and states plainly that no product can. It promises implementation completeness: you will know what is done, what is not, and what each remaining gap costs.
Can the Code Kit be used on client projects?
The SEO for Solos Code Kit covers unlimited projects owned by the licensee or the licensee's employer. An agency licence for client work is not offered, so client projects sit outside the licence.
Find out what you are missing first
All 112 checks and their verification commands are free, with 26 implementation snippets unlocked. Score your site before deciding whether the rest is worth $99.
No testimonials appear on this page because there are none yet. The numbers above come from the audit and are checkable. Invented social proof would not be.