SEO for Solos
2.xcompiler

SvelteKit SEO

A smaller runtime than React and a hooks system that runs before routing, which makes host normalisation cleaner here than anywhere else on this list.

What you start with

  • +svelte:head for per-page metadata
  • +hooks.server.ts, which runs before routing and is the right place for host rules
  • +Prerendering per route with export const prerender
  • +A significantly smaller client runtime than React

What it makes harder

  • !prerender is off by default on most adapters, so a marketing route ships as server-rendered unless you say otherwise
  • !The entries export is what enumerates dynamic routes for prerendering, and a filter in the wrong place silently produces fewer pages
  • !{@html} does not escape, so a JSON-LD block needs the < escape applied by hand

Recipes

Each one is real SvelteKit code, not the same snippet with the imports swapped.

Host normalisation in hooks, before routing

Serving the same content on the apex and the www subdomain splits link equity and doubles the crawl budget spent on identical pages. Check found-canonical-host

src/hooks.server.ts
import { redirect, type Handle } from "@sveltejs/kit";

const CANONICAL_HOST = "www.example.com";

// hooks.server.ts runs before routing, so this applies to every request
// including static assets and endpoints. No framework here gives you a
// cleaner place for it.
export const handle: Handle = async ({ event, resolve }) => {
  const host = event.request.headers.get("host");
  if (host && host !== CANONICAL_HOST && !host.startsWith("localhost")) {
    redirect(301, `https://${CANONICAL_HOST}${event.url.pathname}${event.url.search}`);
  }
  return resolve(event);
};

Canonical from the page store

A relative canonical resolves differently depending on the requested URL, which is exactly the ambiguity a canonical exists to remove. Check meta-canonical-absolute

src/routes/+layout.svelte
<script lang="ts">
  import { page } from "$app/state";
  const SITE = "https://www.example.com";
  let canonical = $derived(SITE + page.url.pathname.replace(/\/$/, ""));
</script>

<svelte:head>
  <link href={canonical} rel="canonical" />
  <meta content={canonical} property="og:url" />
  <meta content="summary_large_image" name="twitter:card" />
</svelte:head>

Entity gate with entries and prerender

An ungated dynamic route generates a page for any slug anyone types, including slugs an attacker generates. That is how a site accidentally publishes ten thousand thin pages. Check pseo-entity-gate

src/routes/glossary/[term]/+page.server.ts
import { error } from "@sveltejs/kit";
import { GLOSSARY_TERMS } from "$lib/data/glossary-terms";

export const prerender = true;

// entries enumerates the dynamic segment for prerendering. Without it,
// SvelteKit only prerenders routes it can reach by crawling links.
export function entries() {
  return GLOSSARY_TERMS.map((term) => ({ term: term.slug }));
}

export function load({ params }) {
  const entry = GLOSSARY_TERMS.find((candidate) => candidate.slug === params.term);
  // error(404) rather than returning an empty object, which would produce a
  // soft 404 with a 200 status.
  if (!entry) error(404, "Not found");
  return { entry };
}

Sitemap as a prerendered endpoint

A hardcoded URL list in a sitemap goes stale the first time a route is added. Composing from the content modules makes the sitemap a projection of reality rather than a claim about it. Check crawl-sitemap-generated

src/routes/sitemap.xml/+server.ts
import { sitemapXml, buildSitemap, PRIORITY } from "$lib/kit/seo/sitemap";
import { getAllSections } from "$lib/content";

export const prerender = true;

export function GET() {
  const entries = buildSitemap(
    [
      {
        name: "playbook",
        items: getAllSections(),
        url: (section) => `/playbook/${section.slug}`,
        lastModified: (section) => section.updatedAt,
        priority: PRIORITY.primaryContent,
        changeFrequency: "monthly",
      },
    ],
    { siteUrl: "https://www.example.com", languages: ["en-US", "x-default"] },
  );

  return new Response(sitemapXml(entries), {
    headers: { "Content-Type": "application/xml; charset=utf-8" },
  });
}

Per-page title and meta description from load data

Duplicate titles across a page tier is the clearest signal that the tier is templated rather than written, and it is the first thing a manual reviewer notices. Check meta-unique-titles

src/routes/blog/[slug]/+page.svelte
<script lang="ts">
  // +page.server.ts returned { post } from its load function.
  let { data } = $props();
</script>

<svelte:head>
  <title>{data.post.title} | Example</title>
  <meta content={data.post.description} name="description" />
  <meta content={data.post.title} property="og:title" />
  <meta content={data.post.description} property="og:description" />
</svelte:head>

JSON-LD in SvelteKit: {@html} in svelte:head with the escaped closing script tag

Multiple disconnected script tags describe several unrelated things. One graph describes one page, and lets nodes reference each other instead of repeating themselves. Check schema-single-script

Three symptoms bring people to this recipe: the JSON-LD renders as visible text on the page because {@html} was skipped, the component compiles but the script block ends early at the closing script tag inside the template literal, or a pasted <script type="application/ld+json">{JSON.stringify(...)} fragment throws a parse error. All three come from the same fact: {@html} does not escape, and the parser does not know your template literal from your component.

src/routes/+layout.svelte
<script lang="ts">
  import { siteGraph } from "$lib/seo";
  const json = JSON.stringify(siteGraph()).replace(/</g, "\\u003c");
</script>

<svelte:head>
  {@html `<script type="application/ld+json">${json}<\/script>`}
</svelte:head>

Where this appears in the playbook

The same checks in other frameworks

Questions

What does SvelteKit give you before you write any SEO code?

SvelteKit starts with: svelte:head for per-page metadata; hooks.server.ts, which runs before routing and is the right place for host rules; Prerendering per route with export const prerender; A significantly smaller client runtime than React.

What does SvelteKit make harder?

prerender is off by default on most adapters, so a marketing route ships as server-rendered unless you say otherwise This is specific to SvelteKit rather than a general SEO problem.

How many SEO recipes does SEO for Solos publish for SvelteKit?

SEO for Solos publishes 6 SvelteKit recipes, each with real SvelteKit code and a note on what differs from the same check in other frameworks.

Product analytics are optional. On the skill page, X advertising measurement is also optional and shares your visit and ad identifiers with X. Both are off until you allow them. Privacy policy.