SvelteKit • Best practices

SEO

On this page

The most important aspect of SEO is to create high-quality content that is widely linked to from around the web. However, there are a few technical considerations for building sites that rank well.

Out of the box

SSR

While search engines have got better in recent years at indexing content that was rendered with client-side JavaScript, server-side rendered content is indexed more frequently and reliably. SvelteKit employs SSR by default, and while you can disable it in handle, you should leave it on unless you have a good reason not to.

SvelteKit’s rendering is highly configurable and you can implement dynamic rendering if necessary. It’s not generally recommended, since SSR has other benefits beyond SEO.

Performance

Signals such as Core Web Vitals impact search engine ranking. Because Svelte and SvelteKit introduce minimal overhead, they make it easier to build high performance sites. You can test your site’s performance using Google’s PageSpeed Insights or Lighthouse. With just a few key actions like using SvelteKit’s default hybrid rendering mode and optimizing your images, you can greatly improve your site’s speed. Read the performance page for more details.

Normalized URLs

SvelteKit redirects pathnames with trailing slashes to ones without (or vice versa depending on your configuration), as duplicate URLs are bad for SEO.

Manual setup

<title> and <meta>

Every page should have well-written and unique <title> and <meta name="description"> elements inside a <svelte:head>. Guidance on how to write descriptive titles and descriptions, along with other suggestions on making content understandable by search engines, can be found on Google’s Lighthouse SEO audits documentation.

A common pattern is to return SEO-related data from page load functions, then use it (as page.data) in a <svelte:head> in your root layout.

Sitemaps

Sitemaps help search engines prioritize pages within your site, particularly when you have a large amount of content. You can create a sitemap dynamically using an endpoint:

export async function function GET(): Promise<Response>function GET(): Promise<Response>GET() {
	return new var Response: new (body?: BodyInit | null, init?: ResponseInit) => Response

The Response interface of the Fetch API represents the response to a request.

MDN Reference

var Response: new (body?: BodyInit | null, init?: ResponseInit) => Response

The Response interface of the Fetch API represents the response to a request.

MDN Reference

Response
(
` <?xml version="1.0" encoding="UTF-8" ?> <urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9" xmlns:xhtml="http://www.w3.org/1999/xhtml" xmlns:mobile="http://www.google.com/schemas/sitemap-mobile/1.0" xmlns:news="http://www.google.com/schemas/sitemap-news/0.9" xmlns:image="http://www.google.com/schemas/sitemap-image/1.1" xmlns:video="http://www.google.com/schemas/sitemap-video/1.1" > <!-- <url> elements go here --> </urlset>`.String.trim(): string

Removes the leading and trailing white space and line terminator characters from a string.

String.trim(): string

Removes the leading and trailing white space and line terminator characters from a string.

trim
(),
{ ResponseInit.headers?: HeadersInit | undefinedResponseInit.headers?: HeadersInit | undefinedheaders: { 'Content-Type': 'application/xml' } } ); }

AMP

An unfortunate reality of modern web development is that it is sometimes necessary to create an Accelerated Mobile Pages (AMP) version of your site. In SvelteKit this can be done by setting the inlineStyleThreshold option…

import type { Config } from '@sveltejs/kit';

const const config: Configconst config: Configconfig: Config = {
	Config.kit?: KitConfig | undefined

SvelteKit options.

@seehttps://svelte.dev/docs/kit/configuration
Config.kit?: KitConfig | undefined

SvelteKit options.

@seehttps://svelte.dev/docs/kit/configuration
kit
: {
// since <link rel="stylesheet"> isn't // allowed, inline all styles KitConfig.inlineStyleThreshold?: number | undefined

Inline CSS inside a <style> block at the head of the HTML. This option is a number that specifies the maximum length of a CSS file in UTF-16 code units, as specified by the String.length property, to be inlined. All CSS files needed for the page that are smaller than this value are merged and inlined in a <style> block.

[!NOTE] This results in fewer initial requests and can improve your First Contentful Paint score. However, it generates larger HTML output and reduces the effectiveness of browser caches. Use it advisedly.

@default0
KitConfig.inlineStyleThreshold?: number | undefined

Inline CSS inside a <style> block at the head of the HTML. This option is a number that specifies the maximum length of a CSS file in UTF-16 code units, as specified by the String.length property, to be inlined. All CSS files needed for the page that are smaller than this value are merged and inlined in a <style> block.

[!NOTE] This results in fewer initial requests and can improve your First Contentful Paint score. However, it generates larger HTML output and reduces the effectiveness of browser caches. Use it advisedly.

@default0
inlineStyleThreshold
: var Infinity: numbervar Infinity: numberInfinity
} }; export default const config: Configconst config: Configconfig;
/** @type {import('@sveltejs/kit').Config} */
const const config: Config
@type{import('@sveltejs/kit').Config}
const config: Config
@type{import('@sveltejs/kit').Config}
config
= {
Config.kit?: KitConfig | undefined

SvelteKit options.

@seehttps://svelte.dev/docs/kit/configuration
Config.kit?: KitConfig | undefined

SvelteKit options.

@seehttps://svelte.dev/docs/kit/configuration
kit
: {
// since <link rel="stylesheet"> isn't // allowed, inline all styles KitConfig.inlineStyleThreshold?: number | undefined

Inline CSS inside a <style> block at the head of the HTML. This option is a number that specifies the maximum length of a CSS file in UTF-16 code units, as specified by the String.length property, to be inlined. All CSS files needed for the page that are smaller than this value are merged and inlined in a <style> block.

[!NOTE] This results in fewer initial requests and can improve your First Contentful Paint score. However, it generates larger HTML output and reduces the effectiveness of browser caches. Use it advisedly.

@default0
KitConfig.inlineStyleThreshold?: number | undefined

Inline CSS inside a <style> block at the head of the HTML. This option is a number that specifies the maximum length of a CSS file in UTF-16 code units, as specified by the String.length property, to be inlined. All CSS files needed for the page that are smaller than this value are merged and inlined in a <style> block.

[!NOTE] This results in fewer initial requests and can improve your First Contentful Paint score. However, it generates larger HTML output and reduces the effectiveness of browser caches. Use it advisedly.

@default0
inlineStyleThreshold
: var Infinity: numbervar Infinity: numberInfinity
} }; export default const config: Config
@type{import('@sveltejs/kit').Config}
const config: Config
@type{import('@sveltejs/kit').Config}
config
;

…disabling csr in your root +layout.js/+layout.server.js

export const const csr: falseconst csr: falsecsr = false;

…adding amp to your app.html

<html amp>
...

…and transforming the HTML using transformPageChunk along with transform imported from @sveltejs/amp:

import * as amp from '@sveltejs/amp';
import type { Handle } from '@sveltejs/kit';

export const handle: Handle = async ({ event, resolve }) => {
	let buffer = '';
	return await resolve(event, {
		transformPageChunk: ({ html, done }) => {
			buffer += html;
			if (done) return amp.transform(buffer);
		}
	});
};
import * as amp from '@sveltejs/amp';

/** @type {import('@sveltejs/kit').Handle} */
export async function handle({ event, resolve }) {
	let buffer = '';
	return await resolve(event, {
		transformPageChunk: ({ html, done }) => {
			buffer += html;
			if (done) return amp.transform(buffer);
		}
	});
}

To prevent shipping any unused CSS as a result of transforming the page to amp, we can use dropcss:

// @filename: ambient.d.ts
declare module 'dropcss';

// @filename: index.ts
// cut
// @errors: 2307
import * as amp from '@sveltejs/amp';
import dropcss from 'dropcss';
import type { Handle } from '@sveltejs/kit';

export const handle: Handle = async ({ event, resolve }) => {
	let buffer = '';

	return await resolve(event, {
		transformPageChunk: ({ html, done }) => {
			buffer += html;

			if (done) {
				let css = '';
				const markup = amp
					.transform(buffer)
					.replace('⚡', 'amp') // dropcss can't handle this character
					.replace(/<style amp-custom([^>]*?)>([^]+?)<\/style>/, (match, attributes, contents) => {
						css = contents;
						return `<style amp-custom${attributes}></style>`;
					});

				css = dropcss({ css, html: markup }).css;
				return markup.replace('</style>', `${css}</style>`);
			}
		}
	});
};
// @filename: ambient.d.ts
declare module 'dropcss';

// @filename: index.js
// cut
// @errors: 2307
import * as amp from '@sveltejs/amp';
import dropcss from 'dropcss';

/** @type {import('@sveltejs/kit').Handle} */
export async function handle({ event, resolve }) {
	let buffer = '';

	return await resolve(event, {
		transformPageChunk: ({ html, done }) => {
			buffer += html;

			if (done) {
				let css = '';
				const markup = amp
					.transform(buffer)
					.replace('⚡', 'amp') // dropcss can't handle this character
					.replace(/<style amp-custom([^>]*?)>([^]+?)<\/style>/, (match, attributes, contents) => {
						css = contents;
						return `<style amp-custom${attributes}></style>`;
					});

				css = dropcss({ css, html: markup }).css;
				return markup.replace('</style>', `${css}</style>`);
			}
		}
	});
}

It’s a good idea to use the handle hook to validate the transformed HTML using amphtml-validator, but only if you’re prerendering pages since it’s very slow.