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" },
  });
}

JSON-LD with the html directive

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

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>

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 5 SvelteKit recipes, each with real SvelteKit code and a note on what differs from the same check in other frameworks.