SvelteKit SEO Configuration

SvelteKit gives you per-route control over whether a page is prerendered to static HTML, server-rendered on demand, or shipped as a client-only shell — and that single choice determines what a crawler actually sees. Configuring these page options deliberately, alongside svelte:head metadata and the right adapter, is the difference between fully indexable content and an empty div that only fills in after hydration.

Prerequisites

Before wiring up SvelteKit for SEO, confirm the following are in place:

  • SvelteKit 2+ with Svelte 4 or 5 — page-option semantics (prerender, ssr, csr) are stable from v2
  • An adapter installed@sveltejs/adapter-cloudflare for edge SSR, or @sveltejs/adapter-node for a long-running server
  • A CMS endpoint returning slugs, titles, descriptions, and body content over REST or GraphQL
  • PUBLIC_SITE_URL set to a protocol-prefixed absolute domain (e.g. https://example.com) in every build environment — never a relative value
  • curl and a headless browser available in CI for validating prerendered output

If you have not yet decided which routes should be static versus dynamic, review ISR vs SSG vs CSR routing first — the page-option choices below map directly onto that decision.

How a Route Flows From Page Option to Crawler

The diagram traces a single request from its page options through the load function and svelte:head block to the adapter output the crawler receives.

SvelteKit SEO rendering flow A five-stage horizontal flow showing a SvelteKit route moving from page options, to a prerender or SSR decision, to the load function, to the svelte:head block, and finally to adapter output for the crawler. SvelteKit Route +page.ts options prerender / ssr decision load() SEO data svelte:head canonical + meta Adapter Output HTML to crawler

Step-by-Step Implementation Workflow

Step 1 — Declare the rendering mode with page options

Every SvelteKit route inherits three booleans that decide how it is rendered. Export them from +page.ts (or a +layout.ts to apply to a whole subtree):

// src/routes/blog/[slug]/+page.ts
export const prerender = true;   // emit static HTML at build time
export const ssr = true;         // render on the server (never false for indexable content)
export const csr = true;         // keep client hydration for interactivity

Setting ssr = false ships an empty shell and is the single most common cause of thin indexed pages in SvelteKit. Leave ssr on for anything a crawler must read. Use prerender = true for content that does not change per request.

Step 2 — Return SEO data from a server load function

Fetch the CMS entry server-side and return the fields the head needs, including a resolved absolute canonical URL:

// src/routes/blog/[slug]/+page.server.ts
import type { PageServerLoad } from './$types';
import { PUBLIC_SITE_URL } from '$env/static/public';
import { getCmsEntry } from '$lib/cms';

export const load: PageServerLoad = async ({ params }) => {
  const entry = await getCmsEntry(params.slug);
  return {
    title: entry.title,
    description: entry.description,
    canonical: `${PUBLIC_SITE_URL}/blog/${params.slug}`,
  };
};

Using a +page.server.ts load keeps the CMS token and the URL resolver on the server. The returned data is serialized into the HTML and rehydrated, so the values are present for crawlers that do not execute JavaScript.

Step 3 — Inject canonical and meta in svelte:head

Bind the load data into the document head:

<!-- src/routes/blog/[slug]/+page.svelte -->
<script lang="ts">
  export let data;
</script>

<svelte:head>
  <title>{data.title}</title>
  <meta name="description" content={data.description} />
  <link rel="canonical" href={data.canonical} />
</svelte:head>

Because load ran on the server, these tags are written into the initial response — this is the SvelteKit equivalent of the SSR-time injection covered in canonical URL enforcement.

Step 4 — Add an adapter and a sitemap endpoint

Choose an adapter in svelte.config.js and expose the sitemap as a prerendered server route:

// src/routes/sitemap.xml/+server.ts
import { PUBLIC_SITE_URL } from '$env/static/public';
import { getAllSlugs } from '$lib/cms';

export const prerender = true;

export async function GET() {
  const slugs = await getAllSlugs();
  const urls = slugs
    .map((s) => `<url><loc>${PUBLIC_SITE_URL}/blog/${s}</loc></url>`)
    .join('');
  const body = `<?xml version="1.0" encoding="UTF-8"?><urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">${urls}</urlset>`;
  return new Response(body, { headers: { 'Content-Type': 'application/xml' } });
}

Step 5 — Validate the built output

Prerender the project and confirm the static HTML contains the tags, or curl the deployed edge route. See the Validation Protocol section below for the exact commands.

Framework-Specific Code Examples

SvelteKit

The entries export lets you prerender dynamic routes by enumerating the params to build. This is essential for CMS-driven pages, which have no static file to infer paths from:

// src/routes/blog/[slug]/+page.ts
import type { EntryGenerator } from './$types';
import { getAllSlugs } from '$lib/cms';

export const prerender = true;
export const trailingSlash = 'never';

export const entries: EntryGenerator = async () => {
  const slugs = await getAllSlugs();
  return slugs.map((slug) => ({ slug }));
};

SEO impact: Every known slug is emitted as a static .html file at build time, giving crawlers the fastest possible first byte from the CDN and a consistent trailing-slash policy that avoids duplicate URL variants.

Validation: After vite build, list the output directory and confirm one .html file exists per slug, each containing the <link rel="canonical"> tag.

Next.js App Router

The equivalent surface in Next.js is route segment config plus generateStaticParams. Instead of entries(), you enumerate params in generateStaticParams and control staticness with export const dynamic. The full pattern lives in Next.js App Router SEO Configuration.

// app/blog/[slug]/page.tsx
export const dynamic = 'force-static';
export async function generateStaticParams() {
  const slugs = await getAllSlugs();
  return slugs.map((slug) => ({ slug }));
}

SEO impact: force-static guarantees the route is generated at build time so crawlers receive complete HTML, matching SvelteKit’s prerender = true behavior.

Validation: Check the Next.js build output table — the route must be marked with the static () symbol, not the dynamic (ƒ) symbol.

Nuxt 3

Nuxt expresses the same intent declaratively through routeRules in nuxt.config, mapping URL patterns to prerender, isr, swr, or ssr. The detailed configuration is covered in Nuxt SEO Configuration.

// nuxt.config.ts
export default defineNuxtConfig({
  routeRules: {
    '/blog/**': { prerender: true },
  },
});

SEO impact: The prerender rule renders matching routes to static HTML during the build, the Nuxt analogue of SvelteKit prerendering.

Validation: Inspect .output/public for the generated HTML files after nuxi build.

HTTP Headers & CDN Directives Reference

Header Required value Rationale
Content-Type text/html; charset=utf-8 Ensures prerendered routes are parsed as HTML, not downloaded
Cache-Control public, max-age=0, s-maxage=86400, stale-while-revalidate=3600 Lets the CDN serve prerendered HTML while revalidating in the background
Link <https://example.com/blog/my-post>; rel="canonical" Delivers the canonical signal at the HTTP layer for edge-rendered routes
X-Robots-Tag index, follow Confirms the adapter is not blocking indexation of production routes
Content-Type (sitemap) application/xml Required for the +server.ts sitemap route to be parsed by crawlers

Validation Protocol

SvelteKit expresses rendering as three independent flags, and the combinations that matter for SEO are a small subset of what they can express.

Flag combinations and their SEO outcome Four combinations of prerender, server rendering, and client rendering flags, each with the crawlability and interactivity outcome. flags outcome prerender, ssr, csr static and interactive — the default choice prerender, ssr, no csr static, zero JavaScript — editorial pages ssr only rendered per request — personalised routes no ssr empty shell — never for indexable content Only the last row is unusable for anything you want indexed

Confirm prerendered HTML contains the canonical

# After building, inspect the emitted static file
grep -i 'rel="canonical"' build/blog/my-post.html
# Expected: <link rel="canonical" href="https://example.com/blog/my-post">

Verify server-rendered content without JavaScript

# Fetch raw HTML from the deployed edge route
curl -s https://example.com/blog/my-post | grep -i '<h1\|canonical\|description'

The <h1>, canonical, and meta description must all appear in the raw response. If they are missing, ssr is likely set to false on that route.

Assert render mode in Lighthouse CI

// lighthouserc.js
module.exports = {
  assert: {
    assertions: {
      'canonical': ['error', { minScore: 1 }],
      'document-title': ['error', { minScore: 1 }],
      'meta-description': ['error', { minScore: 1 }],
    },
  },
};

Troubleshooting

The prerenderer discovers pages by crawling from entry points, which makes an unlinked route invisible to the build unless it is declared explicitly.

Prerender discovery by crawling from entry points An entry point linking to two routes that are discovered, and a third unlinked route that is only built when declared in the config. entry point /blog/ — discovered /about/ — discovered /campaign/ — unlinked declare it in the config Discovery follows links, so anything unlinked needs declaring
Symptom Root cause Fix
Page body empty in view-source export const ssr = false on the route or layout Remove the override or set ssr = true for indexable routes
Dynamic route not prerendered No entries() export, so the builder cannot enumerate params Add an entries generator returning every slug from the CMS
Canonical tag missing in production Canonical built from window.location in a component instead of server load Resolve the absolute URL in +page.server.ts and bind it in svelte:head
Trailing-slash duplicate URLs indexed trailingSlash policy differs between adapter and links Set export const trailingSlash = 'never' and keep internal links consistent
High crawler TTFB on dynamic routes Origin SSR far from the crawler Switch to an edge adapter or prerender the route if content allows — see edge caching behavior for SEO

Pages in This Section

The cost and benefit of assembling it yourself

SvelteKit provides fewer SEO primitives than its peers, and that is a deliberate design choice rather than an omission. The consequence for a technical SEO practitioner is a trade: more of the surface is ordinary application code that behaves exactly as written, and correspondingly more of it is yours to maintain through framework upgrades.

In practice the assembled pieces are small. A sitemap endpoint is a few dozen lines. A canonical is a link element built from one origin constant. Structured data is a script tag whose contents come from a function. None of these is difficult, and each is easier to reason about than the equivalent configuration key, because there is no hidden merge behaviour or precedence rule to discover.

What the approach does demand is discipline about where those pieces live. Scattered across route files, they drift; centralised in a small set of helpers that every route imports, they stay consistent. The framework will not enforce that structure for you, which is precisely why establishing it early — before the route count grows — is the difference between a codebase that stays coherent and one that needs an audit every quarter.

The prerenderer crawls from entry points and errors on links it cannot resolve, which makes every build an internal link check. This is a genuine advantage over frameworks that build routes in isolation: a broken internal link introduced by a CMS edit is caught before deploy rather than by a crawler weeks later.

It also creates an operational consideration worth deciding explicitly. A failing build is the correct behaviour for a codebase change and an awkward one for a content change, because an editor removing a page can block an unrelated release. The workable position for most teams is to fail on links that come from code and warn on links that come from content, with a separate scheduled check covering the second category.

Zero-JavaScript routes and what they are worth

The ability to ship a route with no client JavaScript is SvelteKit’s most distinctive capability and the one most often left unused. For an editorial page — an article, a guide, a documentation entry — nothing on the page is interactive, and disabling client rendering removes the entire hydration cost from a template that gains nothing from it.

The measurable effect is on interaction latency rather than on crawling, since the served HTML is identical either way. What changes is that there is no framework runtime competing with the visitor’s first tap, no bundle to parse on a mid-tier phone, and no hydration mismatch to debug. On a content-heavy site where most sessions land on an article, that is a large share of all page views moved onto a much cheaper path.

The constraint is that the decision is per route and irreversible within it: nothing on the page can become interactive later without re-enabling client rendering for the whole route. That makes it a good fit for templates with a settled design and a poor fit for anything still acquiring features, which is a product judgement rather than a technical one.

Frequently Asked Questions

Does prerender=true generate static HTML at build time? Yes. Setting export const prerender = true tells the SvelteKit builder to render the route to a static .html file during the build. The crawler receives fully formed markup on the first byte with no server compute and no client hydration required to see the content. Pair it with an entries() generator for dynamic routes so the builder knows every path to emit.

How do I inject a canonical tag in SvelteKit? Resolve the absolute canonical URL in a server load function, return it in the page data, and bind it inside a svelte:head block as link rel="canonical". Because the load runs on the server or at build time, the tag is written into the initial HTML rather than added by client JavaScript. This mirrors the layered approach in canonical URL enforcement.

Which adapter minimizes crawler TTFB? For dynamic SSR routes, an edge adapter such as adapter-cloudflare runs the render close to the crawler and returns the first byte fastest. For content that can be static, prerendering with any adapter is faster still because the HTML is served directly from the CDN with no compute. Match the adapter to whether the route is prerendered or server-rendered.


Part of: Framework-Specific SEO Configuration for Headless Stacks

Related