Guide · 10 min read

SvelteKit SEO: the practical checklist

SvelteKit renders on the server by default, so crawlers get real HTML. This checklist covers the rest, with code for SvelteKit 3 and notes for SvelteKit 2.

Updated · By the Serpkin team

In short

  • Keep SSR on and prerender static pages with export const prerender = true. Never use ssr = false on a page you want indexed, and await (don't stream) the data that should rank.
  • Put a unique title and description in <svelte:head> on every page, and a canonical built from your production origin plus page.url.pathname ($app/state).
  • Write src/routes/sitemap.xml/+server.ts yourself, point static/robots.txt at it, and don't block /_app/.
  • Call error(404) for missing records and redirect(308) for moved pages, keep trailingSlash at "never" or "always", and don't let an adapter-static 200.html fallback answer unknown URLs.
  • Give paginated pages self-referencing canonicals, add reciprocal hreflang links with absolute URLs for translations, and send noindex on preview hosts.

SvelteKit renders every page on the server by default, so your content is in the HTML crawlers receive, and Svelte's small runtime keeps pages fast. What it leaves to you: a title and description per page, a canonical tag, a sitemap, robots.txt, and correct status codes for missing and moved pages. This checklist covers those gaps in the order that matters, with code for SvelteKit 3 (current as of October 2026). Where SvelteKit 2 differs, it says so.

Keep SSR on, prerender what you can

SSR is the default and SvelteKit's own SEO docs recommend leaving it on. Never set export const ssr = false on a page you want found: the server then sends an empty shell and the page only exists after JavaScript runs. Google renders JavaScript later and not always fully, and many AI crawlers don't run it at all.

Pages that show the same content to every visitor (marketing pages, docs, blog posts) can be prerendered to static HTML at build time:

src/routes/blog/[slug]/+page.ts
export const prerender = true;

Put it in src/routes/+layout.ts to prerender the whole site (required with adapter-static). Two things to know:

  • The prerenderer finds pages by following <a href> links from the entry points. A dynamic page nothing links to won't be built unless you list it with an entries export (below). That is also a hint that crawlers will struggle to find it.
  • prerender.handleHttpError and handleMissingId default to "fail", so a broken internal link or a #hash link to a missing id fails the build. Keep those defaults.
  • Reading url.searchParams while prerendering is an error. A page that depends on ?page=2 or ?sort=price has to render on demand, or use path segments instead.
src/routes/blog/[slug]/+page.server.ts
import { getPosts } from "#lib/server/posts.js";

export const prerender = true;

// Every slug to build, even posts nothing links to yet.
export async function entries() {
  const posts = await getPosts();
  return posts.map((p) => ({ slug: p.slug }));
}
Page options and what crawlers get
OptionWhat the crawler receivesUse it for
Default (SSR)Full HTML on each requestDynamic or personalized pages
prerender = trueFull HTML, built onceMarketing pages, docs, blog posts
csr = falseFull HTML, no JavaScriptPure content pages with no interactivity
ssr = falseAn empty shellNever on pages you want indexed

csr = false doesn't change what crawlers see; it only drops the JavaScript. The trade-offs: component <script> code doesn't run in the browser, forms can't be progressively enhanced, and links become full page loads. Use it page by page on legal pages or posts, not on an app's root layout.

Every page needs a unique <title> and <meta name="description">. Put them in <svelte:head> in the page component, using data from load for dynamic routes:

src/routes/blog/[slug]/+page.server.ts
import { error } from "@sveltejs/kit";
import { getPost } from "#lib/server/posts.js";

export async function load({ params }) {
  const post = await getPost(params.slug);
  if (!post) error(404, "Post not found"); // a real 404, not a soft 404
  return { post };
}
src/routes/blog/[slug]/+page.svelte
<script lang="ts">
  let { data } = $props();
</script>

<svelte:head>
  <title>{data.post.title} | Acme</title>
  <meta name="description" content={data.post.summary} />
  <meta property="og:title" content={data.post.title} />
  <meta property="og:description" content={data.post.summary} />
  <meta property="og:image" content={`https://your-site.com/og/${data.post.slug}.png`} />
</svelte:head>

<article>
  <h1>{data.post.title}</h1>
  {@html data.post.html}
</article>

If many pages share the same pattern, return an seo object from each load and render the tags once in the root layout's <svelte:head>, reading it from page.data. Either way, check that a page doesn't end up with two titles.

Canonical URLs with page.url

SvelteKit doesn't add a canonical tag. Add one in the root layout from the current path. Use page from $app/state: $app/stores (and $page) was removed in SvelteKit 3. On SvelteKit 2.12 and later, $app/state already works.

src/routes/+layout.svelte
<script lang="ts">
  import { page } from "$app/state";
  let { children } = $props();

  const SITE = "https://your-site.com"; // production origin, not page.url.origin
  const canonical = $derived(new URL(page.url.pathname, SITE).href);
</script>

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

{@render children()}

Build the canonical from a fixed production origin, not page.url.origin. On a preview deploy the origin is the preview host, and during prerendering it is http://sveltekit-prerender unless you set paths.origin (called prerender.origin before SvelteKit 3). Using pathname also drops query strings, which is usually what you want. The next section covers pagination, filters and languages; our canonical tags guide covers the other duplicate cases.

Pagination, filters and languages

Paginated and filtered lists

Google treats /blog?page=2 as its own page. Give each page in a series a self-referencing canonical (don't point them all at page 1) and link to the next page with a real <a href>. Since pathname drops the query, a page that keeps ?page=n must add it back to its canonical. If you prerender the blog, use a path route like src/routes/blog/page/[n]/+page.server.ts instead, because prerendering can't read query strings.

Sort and filter variants (?sort=price, ?color=red) show the same items in another order. Google suggests keeping them out of the index: add a noindex robots meta tag on those variants, or block the parameter pattern in robots.txt.

hreflang for translated pages

If you serve /en/pricing and /de/pricing, each version must list itself and every other version with absolute URLs, plus an x-default fallback. Google ignores the tags unless the pages point at each other. Each locale keeps its own canonical; never canonicalize translations to the English page.

src/routes/[lang]/+layout.svelte
<script lang="ts">
  import { page } from "$app/state";
  let { children } = $props();

  const SITE = "https://your-site.com";
  const LOCALES = ["en", "de", "fr"];
  // "/de/pricing" -> "/pricing"
  const rest = $derived(page.url.pathname.replace(/^\/[a-z]{2}(?=\/|$)/, ""));
</script>

<svelte:head>
  {#each LOCALES as lang}
    <link rel="alternate" hreflang={lang} href={`${SITE}/${lang}${rest}`} />
  {/each}
  <link rel="alternate" hreflang="x-default" href={`${SITE}/en${rest}`} />
</svelte:head>

{@render children()}

Set <html lang> per locale too, for example by replacing a %lang% placeholder in src/app.html with transformPageChunk in handle. If an i18n library such as Paraglide already emits alternate links, don't add a second set.

One URL per page: trailingSlash

SvelteKit redirects /about/ to /about by default (trailingSlash: "never"). If your existing URLs end in a slash, switch the whole site in the root layout:

src/routes/+layout.ts
export const trailingSlash = "always"; // "never" (default) | "always" | "ignore"

Avoid "ignore". The docs call it out as bad for SEO because /x and /x/ become two URLs for the same page. Whatever you pick, use the same style in canonicals, the sitemap and internal links, so nothing points at a redirect.

Sitemap and robots.txt endpoints

sitemap.xml

SvelteKit has no sitemap generator. Write a +server.ts endpoint that lists your canonical, indexable URLs, and prerender it if the list only changes on deploy:

src/routes/sitemap.xml/+server.ts
import { getPosts } from "#lib/server/posts.js";

export const prerender = true;
const SITE = "https://your-site.com";

export async function GET() {
  const posts = await getPosts();
  const urls = [
    { loc: "/" },
    { loc: "/pricing" },
    ...posts.map((p) => ({ loc: `/blog/${p.slug}`, lastmod: p.updatedAt })),
  ];
  const body = `<?xml version="1.0" encoding="UTF-8"?>
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
${urls
  .map((u) => `  <url><loc>${SITE}${u.loc}</loc>${u.lastmod ? `<lastmod>${u.lastmod}</lastmod>` : ""}</url>`)
  .join("\n")}
</urlset>`;
  return new Response(body, { headers: { "Content-Type": "application/xml" } });
}

Slugs from a CMS can contain &, which must be escaped as &amp; in XML. Leave out drafts, noindex pages and anything that redirects.

robots.txt

A static file in static/robots.txt is enough for most sites:

static/robots.txt
User-agent: *
Allow: /

Sitemap: https://your-site.com/sitemap.xml

Keep preview and staging hosts out of the index

A Disallow: / on staging doesn't remove URLs Google already found, and it is easy to ship to production by accident. A noindex header on every non-production host is safer. Add it in handle:

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

const PROD_HOST = "your-site.com";

export const handle: Handle = async ({ event, resolve }) => {
  const response = await resolve(event);
  if (event.url.hostname !== PROD_HOST) {
    response.headers.set("X-Robots-Tag", "noindex");
  }
  return response;
};

Prerendered pages are served as static files and skip handle at request time, so on those the fixed-origin canonical is your safety net (or set the header in your host's config for preview deploys). Check with curl -sI https://your-preview-url/ | grep -i x-robots.

JSON-LD in svelte:head

Svelte doesn't interpolate inside a <script> tag in markup, so render the whole JSON-LD tag with {@html}. {@html} doesn't escape anything, so escape < in the JSON; otherwise a title containing </script> can break out of the tag.

src/routes/blog/[slug]/+page.svelte
<script lang="ts">
  let { data } = $props();

  const jsonLd = $derived(
    JSON.stringify({
      "@context": "https://schema.org",
      "@type": "Article",
      headline: data.post.title,
      datePublished: data.post.publishedAt,
      dateModified: data.post.updatedAt,
      author: { "@type": "Person", name: data.post.author },
    }).replace(/</g, "\\u003c"),
  );
</script>

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

Mark up only what's visible on the page and validate it in the Rich Results Test. Our structured data guide covers which types to start with.

Error pages, status codes and redirects

Return real 404s

Call error(404, "...") in load when a record doesn't exist, as in the blog example above. The status becomes the HTTP status of the response and SvelteKit renders the nearest +error.svelte. In SvelteKit 2 and 3 you call error() and redirect(); you don't throw them (that was SvelteKit 1).

src/routes/+error.svelte
<script lang="ts">
  import { page } from "$app/state";
</script>

<svelte:head>
  <title>{page.status} | Acme</title>
</svelte:head>

<h1>{page.status}: {page.error?.message}</h1>
<p><a href="/">Back to the homepage</a></p>

Redirect moved pages permanently

redirect() accepts 300 to 308. Use 308 for a permanent move (the docs' choice for SEO; Google treats 301 and 308 the same) and 307 or 303 for temporary ones. Don't call it inside a try block, because it works by throwing.

src/routes/blog/old-slug/+page.server.ts
import { redirect } from "@sveltejs/kit";

export function load() {
  redirect(308, "/blog/new-slug");
}

For many renamed URLs, keep a map in src/hooks.server.ts and return a 308 response from handle before routing. On a fully static site (adapter-static) there is no server, so put redirects in your host's config instead. Redirects to another domain need { external: true } in SvelteKit 3.

The adapter-static fallback trap

adapter-static with a fallback page (often 200.html) turns unknown URLs into SPA mode: the host serves the fallback shell, usually with a 200 status, and the app renders "not found" in the browser. To Google that is a soft 404, and the shell has no content for any route that wasn't prerendered. SvelteKit's docs recommend fallback mode only in special cases such as wrapping the site in a mobile app. Prerender every page, leave fallback unset (or use 404.html on hosts like GitHub Pages that serve it with a 404), and test a made-up URL:

Terminal
curl -s -o /dev/null -w "%{http_code}\n" https://your-site.com/this-page-does-not-exist
# want: 404

Troubleshooting: symptom, cause, fix

Common SvelteKit SEO problems and how to fix them
SymptomLikely causeFix
curl shows a spinner or empty list where the content should beAn un-awaited promise returned from load is streamedAwait the content that should rank; stream only secondary data
Canonical or og:url starts with http://sveltekit-prerenderBuilt from page.url.origin during prerenderingUse a fixed production origin, or set paths.origin
Unknown URLs return 200Host serves the 200.html fallback, or a page renders "not found" without error(404)Call error(404) in load; drop the fallback
A dynamic page is missing from the buildNothing links to it, so the prerenderer never found itAdd an entries export, and link to it
Build fails on url.searchParamsA prerendered page reads the query stringRender it on demand (prerender = false) or move the value into the path
Two titles or two canonicals in the headBoth the layout and the page set them in <svelte:head>Set each tag in one place only
Redirect never happens; the error page shows insteadredirect() called inside try, so catch swallows itCall redirect() outside the try block
Preview URLs show up in GooglePreview host is crawlable and its canonical points at itselfFixed-origin canonical plus an X-Robots-Tag: noindex header off production

Links

Navigate with real <a href> elements. SvelteKit's client router intercepts them, so you lose nothing. A button that calls goto() is invisible to crawlers and to the prerenderer. Keep the template's data-sveltekit-preload-data="hover" (in SvelteKit 3 the off value is "false", not "off"). Link related pages with descriptive anchor text; see our internal linking guide.

Images

@sveltejs/enhanced-img generates modern formats and sizes at build time and adds width and height, which prevents layout shift. Add its Vite plugin before SvelteKit's:

vite.config.ts
import { sveltekit } from "@sveltejs/kit/vite";
import { enhancedImages } from "@sveltejs/enhanced-img";
import adapter from "@sveltejs/adapter-auto";
import { defineConfig } from "vite";

export default defineConfig({
  plugins: [
    enhancedImages(), // must come before sveltekit()
    sveltekit({ adapter: adapter() }), // SvelteKit 3: config lives here, not svelte.config.js
  ],
});
src/routes/+page.svelte
<enhanced:img
  src="./hero.jpg"
  alt="The Acme board view with three columns of tasks"
  sizes="min(1280px, 100vw)"
  fetchpriority="high"
/>

Don't lazy-load the LCP image. Use alt="" only for decorative images.

Check it before you ship

  • curl -s on each page shows the H1 and main copy. No indexable page uses ssr = false.
  • Every page has one unique title and description in <svelte:head>.
  • The canonical uses your production origin plus page.url.pathname, in your trailing-slash style.
  • trailingSlash is "never" or "always", not "ignore".
  • /sitemap.xml lists only 200, canonical, indexable URLs, and static/robots.txt points to it.
  • Missing records call error(404); moved URLs return 308 (or 301).
  • JSON-LD escapes < and validates in the Rich Results Test.
  • Navigation uses <a href>, and images have dimensions and alt.
  • Content that should rank is awaited in load, not streamed.
  • A made-up URL returns 404, and preview hosts send noindex.

Most of this list can be checked from a terminal against production:

Terminal
URL=https://your-site.com/blog/hello-world
curl -s $URL | grep -o "<h1[^>]*>[^<]*"                # H1 in the server HTML
curl -s $URL | grep -oE '<link rel="(canonical|alternate)"[^>]*>'
curl -s -o /dev/null -w "%{http_code} %{redirect_url}\n" $URL/   # expect 308 to the no-slash URL
curl -s -o /dev/null -w "%{http_code}\n" https://your-site.com/nope   # expect 404
curl -s https://your-site.com/sitemap.xml | grep -c "<loc>"

To spot-check one URL (server HTML, title, canonical, robots, sitemap, structured data, Open Graph), use the free SEO check. To catch regressions across pages before they reach production, compare a preview deploy with your live site:

  1. Deploy a preview

    Push your branch and wait for the preview URL (for example a vercel.app, netlify.app or pages.dev URL).

  2. Compare it with production
    Terminal
    npx serpkin check https://your-branch.vercel.app

    Serpkin compares titles, descriptions, canonicals, noindex, status codes, robots.txt, the sitemap and JSON-LD on the preview against production. It exits with code 1 on a regression, so it also works in CI. A noindex header on the preview (like the hook above adds) is reported as a warning, not a regression; a noindex meta tag in your page code fails the check.

  3. Fix, then merge

    Serpkin maps Next.js routes to source files fully. For SvelteKit the route map is best effort (it reads +page.svelte files under src/routes), so a report may name the page rather than every file involved.

Check a page nowFree: see what a crawler finds on any URL, with a prompt for your agent.

Questions

Is SvelteKit good for SEO?

Yes. It server-renders pages by default, can prerender them to static HTML, and ships little JavaScript, which helps Core Web Vitals. You still need to add titles, descriptions, canonicals, a sitemap and correct status codes.

How do I add a sitemap to SvelteKit?

Create src/routes/sitemap.xml/+server.ts with a GET handler that returns XML and a Content-Type of application/xml. Add export const prerender = true if the URL list only changes when you deploy.

Should I use $app/state or $app/stores?

Use $app/state. It works from SvelteKit 2.12, and SvelteKit 3 removed $app/stores. Read page.url and page.data directly, without the $ prefix.

Does SvelteKit need ssr = false or csr = false for SEO?

Never ssr = false on indexable pages: it sends an empty shell. csr = false keeps the same HTML but ships no JavaScript, so it is a speed choice for pure content pages, not an SEO requirement.

Does streaming data from load hurt SEO?

It can. Promises returned without await are streamed, and streaming only works with JavaScript enabled, so crawlers that read raw HTML see your loading state. Await the main content and stream only secondary data such as comments.

Sources

Let your agent do the SEO work.

Serpkin finds the next fix from real search data, hands it to Claude Code, Codex or Cursor, and checks it after you ship.

npx serpkin
Start free