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:
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 anentriesexport (below). That is also a hint that crawlers will struggle to find it. prerender.handleHttpErrorandhandleMissingIddefault to"fail", so a broken internal link or a#hashlink to a missing id fails the build. Keep those defaults.- Reading
url.searchParamswhile prerendering is an error. A page that depends on?page=2or?sort=pricehas to render on demand, or use path segments instead.
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 }));
}| Option | What the crawler receives | Use it for |
|---|---|---|
| Default (SSR) | Full HTML on each request | Dynamic or personalized pages |
prerender = true | Full HTML, built once | Marketing pages, docs, blog posts |
csr = false | Full HTML, no JavaScript | Pure content pages with no interactivity |
ssr = false | An empty shell | Never 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.
Titles and descriptions with svelte:head
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:
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 };
}<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.
<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.
<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:
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:
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 & 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:
User-agent: *
Allow: /
Sitemap: https://your-site.com/sitemap.xmlKeep 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:
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.
<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).
<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.
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:
curl -s -o /dev/null -w "%{http_code}\n" https://your-site.com/this-page-does-not-exist
# want: 404Troubleshooting: symptom, cause, fix
| Symptom | Likely cause | Fix |
|---|---|---|
| curl shows a spinner or empty list where the content should be | An un-awaited promise returned from load is streamed | Await the content that should rank; stream only secondary data |
Canonical or og:url starts with http://sveltekit-prerender | Built from page.url.origin during prerendering | Use a fixed production origin, or set paths.origin |
| Unknown URLs return 200 | Host 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 build | Nothing links to it, so the prerenderer never found it | Add an entries export, and link to it |
Build fails on url.searchParams | A prerendered page reads the query string | Render it on demand (prerender = false) or move the value into the path |
| Two titles or two canonicals in the head | Both the layout and the page set them in <svelte:head> | Set each tag in one place only |
| Redirect never happens; the error page shows instead | redirect() called inside try, so catch swallows it | Call redirect() outside the try block |
| Preview URLs show up in Google | Preview host is crawlable and its canonical points at itself | Fixed-origin canonical plus an X-Robots-Tag: noindex header off production |
Crawlable links and images
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:
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
],
});<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 -son each page shows the H1 and main copy. No indexable page usesssr = 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. trailingSlashis"never"or"always", not"ignore"./sitemap.xmllists only 200, canonical, indexable URLs, andstatic/robots.txtpoints 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 andalt. - 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:
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:
- Deploy a preview
Push your branch and wait for the preview URL (for example a vercel.app, netlify.app or pages.dev URL).
- Compare it with production
Terminal npx serpkin check https://your-branch.vercel.appSerpkin 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
noindexheader on the preview (like the hook above adds) is reported as a warning, not a regression; anoindexmeta tag in your page code fails the check. - Fix, then merge
Serpkin maps Next.js routes to source files fully. For SvelteKit the route map is best effort (it reads
+page.sveltefiles undersrc/routes), so a report may name the page rather than every file involved.
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
- SvelteKit docs: SEO (checked October 27, 2026)
- SvelteKit docs: page options (prerender, ssr, csr, trailingSlash) (checked October 27, 2026)
- SvelteKit docs: $app/state (checked October 27, 2026)
- SvelteKit docs: migrating to SvelteKit 3 (checked October 27, 2026)
- SvelteKit docs: loading data (streaming, error and redirect) (checked October 27, 2026)
- SvelteKit docs: errors (checked October 27, 2026)
- SvelteKit docs: link options (checked October 27, 2026)
- SvelteKit docs: images (@sveltejs/enhanced-img) (checked October 27, 2026)
- SvelteKit docs: adapter-static (fallback pages) (checked October 27, 2026)
- SvelteKit docs: single-page apps (SEO impact of fallback mode) (checked October 27, 2026)
- SvelteKit docs: hooks (handle, transformPageChunk) (checked October 27, 2026)
- Google: tell Google about localized versions of your page (checked October 27, 2026)
- Google: pagination and incremental page loading (checked October 27, 2026)
- SvelteKit docs: configuration (prerender options, paths.origin) (checked October 27, 2026)
- Svelte docs: {@html} (checked October 27, 2026)