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-cloudflarefor edge SSR, or@sveltejs/adapter-nodefor a long-running server - A CMS endpoint returning slugs, titles, descriptions, and body content over REST or GraphQL
PUBLIC_SITE_URLset to a protocol-prefixed absolute domain (e.g.https://example.com) in every build environment β never a relative valuecurland 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.
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
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
| 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
- Prerendering Routes for SEO in SvelteKit β a focused walkthrough of
prerender,entries(), trailing-slash policy, and verifying the emitted static HTML
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
- Next.js App Router SEO Configuration β the equivalent segment-config and metadata surface in Next.js
- Nuxt SEO Configuration β declarative per-route rendering through
routeRules - Canonical URL Enforcement β resolve and inject absolute canonicals at SSR and build time
- ISR vs SSG vs CSR Routing β choose which routes should be static, server-rendered, or client-only
- Edge Caching Behavior for SEO β keep prerendered HTML and canonical headers cached at the edge